import { Controller, Post, Get, Body, Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, UseGuards, Inject, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBadRequestResponse, ApiInternalServerErrorResponse, ApiBearerAuth, ApiQuery, } from '@nestjs/swagger'; import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; import { RateQuoteMapper } from '../mappers'; import { RateSearchService } from '@domain/services/rate-search.service'; import { CsvRateSearchService } from '@domain/services/csv-rate-search.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; import { AvailableCompaniesDto, FilterOptionsDto, AvailableOriginsDto, AvailableDestinationsDto, AvailableDirectionsDto, RoutePortInfoDto, } from '../dto/csv-rate-upload.dto'; import { CsvRateMapper } from '../mappers/csv-rate.mapper'; import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository'; @ApiTags('Rates') @Controller('rates') @ApiBearerAuth() export class RatesController { private readonly logger = new Logger(RatesController.name); constructor( private readonly rateSearchService: RateSearchService, private readonly csvRateSearchService: CsvRateSearchService, private readonly csvRateMapper: CsvRateMapper, @Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository ) {} @Post('search') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Search shipping rates', description: 'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Rate search completed successfully', type: RateSearchResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', schema: { example: { statusCode: 400, message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'], error: 'Bad Request', }, }, }) @ApiInternalServerErrorResponse({ description: 'Internal server error', }) async searchRates( @Body() dto: RateSearchRequestDto, @CurrentUser() user: UserPayload ): Promise { const startTime = Date.now(); this.logger.log( `[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}` ); try { // Convert DTO to domain input const searchInput = { origin: dto.origin, destination: dto.destination, containerType: dto.containerType, mode: dto.mode, departureDate: new Date(dto.departureDate), quantity: dto.quantity, weight: dto.weight, volume: dto.volume, isHazmat: dto.isHazmat, imoClass: dto.imoClass, }; // Execute search const result = await this.rateSearchService.execute(searchInput); // Convert domain entities to DTOs const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes); const responseTimeMs = Date.now() - startTime; this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`); return { quotes: quoteDtos, count: quoteDtos.length, origin: dto.origin, destination: dto.destination, departureDate: dto.departureDate, containerType: dto.containerType, mode: dto.mode, fromCache: false, // TODO: Implement cache detection responseTimeMs, }; } catch (error: any) { this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack); throw error; } } /** * Search CSV-based rates with advanced filters */ @Post('search-csv') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Search CSV-based rates with advanced filters', description: 'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.', }) @ApiResponse({ status: HttpStatus.OK, description: 'CSV rate search completed successfully', type: CsvRateSearchResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', }) async searchCsvRates( @Body() dto: CsvRateSearchDto, @CurrentUser() user: UserPayload ): Promise { const startTime = Date.now(); this.logger.log( `[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg` ); try { const searchInput = { origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, containerType: dto.containerType, hasDangerousGoods: dto.hasDangerousGoods ?? false, direction: dto.direction, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; const result = await this.csvRateSearchService.execute(searchInput); // Map domain output to response DTO const response = this.csvRateMapper.mapSearchOutputToResponseDto(result); const responseTimeMs = Date.now() - startTime; this.logger.log( `CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms` ); return response; } catch (error: any) { this.logger.error( `CSV rate search failed: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Search CSV-based rates with service level offers (RAPID, STANDARD, ECONOMIC) */ @Post('search-csv-offers') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Search CSV-based rates with service level offers', description: 'Search for rates from CSV-loaded carriers and generate 3 service level offers for each matching rate: RAPID (20% more expensive, 30% faster), STANDARD (base price and transit), ECONOMIC (15% cheaper, 50% slower). Results are sorted by price (cheapest first).', }) @ApiResponse({ status: HttpStatus.OK, description: 'CSV rate search with offers completed successfully', type: CsvRateSearchResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', }) async searchCsvRatesWithOffers( @Body() dto: CsvRateSearchDto, @CurrentUser() user: UserPayload ): Promise { const startTime = Date.now(); this.logger.log( `[User: ${user.email}] Searching CSV rates with offers: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg` ); try { const searchInput = { origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, containerType: dto.containerType, hasDangerousGoods: dto.hasDangerousGoods ?? false, direction: dto.direction, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; const result = await this.csvRateSearchService.executeWithOffers(searchInput); // Map domain output to response DTO const response = this.csvRateMapper.mapSearchOutputToResponseDto(result); const responseTimeMs = Date.now() - startTime; this.logger.log( `CSV rate search with offers completed: ${response.totalResults} results (including 3 offers per rate), ${responseTimeMs}ms` ); return response; } catch (error: any) { this.logger.error( `CSV rate search with offers failed: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get the trade directions that currently have usable rate grids. * Lets the booking wizard skip the import/export step when only one applies. */ @Get('available-routes/directions') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get available trade directions', description: 'Returns the trade directions (EXPORT / IMPORT) for which at least one usable rate grid exists. An empty or single-entry list means the direction choice can be skipped in the UI.', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of available trade directions', type: AvailableDirectionsDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async getAvailableDirections(): Promise { this.logger.log('Fetching available trade directions from CSV rates'); try { const directions = await this.csvRateSearchService.getAvailableDirections(); return { directions }; } catch (error: any) { this.logger.error( `Failed to fetch available directions: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get available origin ports from CSV rates * Returns only ports that have routes defined in CSV files */ @Get('available-routes/origins') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get available origin ports from CSV rates', description: 'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.', }) @ApiQuery({ name: 'direction', required: false, enum: ['EXPORT', 'IMPORT'], description: 'Restrict to grids of this trade direction (EXPORT = French origins)', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of available origin ports with details', type: AvailableOriginsDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async getAvailableOrigins(@Query('direction') direction?: string): Promise { this.logger.log( `Fetching available origin ports from CSV rates${direction ? ` (${direction})` : ''}` ); try { // Get unique origin port codes from CSV rates const originCodes = await this.csvRateSearchService.getAvailableOrigins( this.parseDirection(direction) ); // Fetch port details from database const ports = await this.portRepository.findByCodes(originCodes); // Map to response DTO with port details const origins: RoutePortInfoDto[] = originCodes.map(code => { const port = ports.find(p => p.code === code); if (port) { return { code: port.code, name: port.name, city: port.city, country: port.country, countryName: port.countryName, displayName: port.getDisplayName(), latitude: port.coordinates.latitude, longitude: port.coordinates.longitude, }; } // Fallback if port not found in database return { code, name: code, city: '', country: code.substring(0, 2), countryName: '', displayName: code, }; }); // Sort by display name origins.sort((a, b) => a.displayName.localeCompare(b.displayName)); return { origins, total: origins.length, }; } catch (error: any) { this.logger.error( `Failed to fetch available origins: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get available destination ports for a given origin * Returns only destinations that have routes from the specified origin in CSV files */ @Get('available-routes/destinations') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get available destination ports for a given origin', description: 'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.', }) @ApiQuery({ name: 'origin', required: true, description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)', example: 'NLRTM', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of available destination ports with details', type: AvailableDestinationsDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiQuery({ name: 'direction', required: false, enum: ['EXPORT', 'IMPORT'], description: 'Restrict to grids of this trade direction (EXPORT = French origins)', }) @ApiBadRequestResponse({ description: 'Origin port code is required', }) async getAvailableDestinations( @Query('origin') origin: string, @Query('direction') direction?: string ): Promise { this.logger.log(`Fetching available destinations for origin: ${origin}`); if (!origin) { throw new Error('Origin port code is required'); } try { // Get destination port codes for this origin from CSV rates const destinationCodes = await this.csvRateSearchService.getAvailableDestinations( origin, this.parseDirection(direction) ); // Fetch port details from database const ports = await this.portRepository.findByCodes(destinationCodes); // Map to response DTO with port details const destinations: RoutePortInfoDto[] = destinationCodes.map(code => { const port = ports.find(p => p.code === code); if (port) { return { code: port.code, name: port.name, city: port.city, country: port.country, countryName: port.countryName, displayName: port.getDisplayName(), latitude: port.coordinates.latitude, longitude: port.coordinates.longitude, }; } // Fallback if port not found in database return { code, name: code, city: '', country: code.substring(0, 2), countryName: '', displayName: code, }; }); // Sort by display name destinations.sort((a, b) => a.displayName.localeCompare(b.displayName)); return { origin: origin.toUpperCase(), destinations, total: destinations.length, }; } catch (error: any) { this.logger.error( `Failed to fetch available destinations: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get available companies */ @Get('companies') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get available carrier companies', description: 'Returns list of all available carrier companies in the CSV rate system.', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of available companies', type: AvailableCompaniesDto, }) async getCompanies(): Promise { this.logger.log('Fetching available companies'); try { const companies = await this.csvRateSearchService.getAvailableCompanies(); return { companies, total: companies.length, }; } catch (error: any) { this.logger.error( `Failed to fetch companies: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get filter options */ @Get('filters/options') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get available filter options', description: 'Returns available options for all filters (companies, container types, currencies).', }) @ApiResponse({ status: HttpStatus.OK, description: 'Available filter options', type: FilterOptionsDto, }) async getFilterOptions(): Promise { this.logger.log('Fetching filter options'); try { const [companies, containerTypes] = await Promise.all([ this.csvRateSearchService.getAvailableCompanies(), this.csvRateSearchService.getAvailableContainerTypes(), ]); return { companies, containerTypes, currencies: ['USD', 'EUR'], }; } catch (error: any) { this.logger.error( `Failed to fetch filter options: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** Normalize the direction query param; anything unrecognized means "no filter". */ private parseDirection(direction?: string): 'EXPORT' | 'IMPORT' | undefined { const upper = direction?.trim().toUpperCase(); return upper === 'EXPORT' || upper === 'IMPORT' ? upper : undefined; } }