import { Controller, Get, Post, Param, Body, Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, NotFoundException, ParseUUIDPipe, ParseIntPipe, DefaultValuePipe, UseGuards, Res, StreamableFile, Inject, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBadRequestResponse, ApiNotFoundResponse, ApiInternalServerErrorResponse, ApiQuery, ApiParam, ApiBearerAuth, ApiProduces, } from '@nestjs/swagger'; import { Response } from 'express'; import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto'; import { BookingFilterDto } from '../dto/booking-filter.dto'; import { BookingExportDto } from '../dto/booking-export.dto'; import { BookingMapper } from '../mappers'; import { BookingService } from '@domain/services/booking.service'; import { BookingRepository, BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { RateQuoteRepository, RATE_QUOTE_REPOSITORY, } from '@domain/ports/out/rate-quote.repository'; import { BookingNumber } from '@domain/value-objects/booking-number.vo'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { ExportService } from '../services/export.service'; import { FuzzySearchService } from '../services/fuzzy-search.service'; import { AuditService } from '../services/audit.service'; import { AuditAction } from '@domain/entities/audit-log.entity'; import { NotificationService } from '../services/notification.service'; import { NotificationsGateway } from '../gateways/notifications.gateway'; import { WebhookService } from '../services/webhook.service'; import { WebhookEvent } from '@domain/entities/webhook.entity'; @ApiTags('Bookings') @Controller('bookings') @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class BookingsController { private readonly logger = new Logger(BookingsController.name); constructor( private readonly bookingService: BookingService, @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, private readonly exportService: ExportService, private readonly fuzzySearchService: FuzzySearchService, private readonly auditService: AuditService, private readonly notificationService: NotificationService, private readonly notificationsGateway: NotificationsGateway, private readonly webhookService: WebhookService ) {} @Post() @HttpCode(HttpStatus.CREATED) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Create a new booking', description: 'Create a new booking based on a rate quote. The booking will be in "draft" status initially. Requires authentication.', }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Booking created successfully', type: BookingResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', }) @ApiNotFoundResponse({ description: 'Rate quote not found', }) @ApiInternalServerErrorResponse({ description: 'Internal server error', }) async createBooking( @Body() dto: CreateBookingRequestDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); try { // Convert DTO to domain input, using authenticated user's data const input = { ...BookingMapper.toCreateBookingInput(dto), userId: user.id, organizationId: user.organizationId, }; // Create booking via domain service const booking = await this.bookingService.createBooking(input); // Fetch rate quote for response const rateQuote = await this.rateQuoteRepository.findById(dto.rateQuoteId); if (!rateQuote) { throw new NotFoundException(`Rate quote ${dto.rateQuoteId} not found`); } // Convert to DTO const response = BookingMapper.toDto(booking, rateQuote); this.logger.log( `Booking created successfully: ${booking.bookingNumber.value} (${booking.id})` ); // Audit log: Booking created await this.auditService.logSuccess( AuditAction.BOOKING_CREATED, user.id, user.email, user.organizationId, { resourceType: 'booking', resourceId: booking.id, resourceName: booking.bookingNumber.value, metadata: { rateQuoteId: dto.rateQuoteId, status: booking.status.value, carrier: rateQuote.carrierName, }, } ); // Send real-time notification try { const notification = await this.notificationService.notifyBookingCreated( user.id, user.organizationId, booking.bookingNumber.value, booking.id ); await this.notificationsGateway.sendNotificationToUser(user.id, notification); } catch (error: any) { // Don't fail the booking creation if notification fails this.logger.error(`Failed to send notification: ${error?.message}`); } // Trigger webhooks try { await this.webhookService.triggerWebhooks( WebhookEvent.BOOKING_CREATED, user.organizationId, { bookingId: booking.id, bookingNumber: booking.bookingNumber.value, status: booking.status.value, shipper: booking.shipper, consignee: booking.consignee, carrier: rateQuote.carrierName, origin: rateQuote.origin, destination: rateQuote.destination, etd: rateQuote.etd?.toISOString(), eta: rateQuote.eta?.toISOString(), createdAt: booking.createdAt.toISOString(), } ); } catch (error: any) { // Don't fail the booking creation if webhook fails this.logger.error(`Failed to trigger webhooks: ${error?.message}`); } return response; } catch (error: any) { this.logger.error( `Booking creation failed: ${error?.message || 'Unknown error'}`, error?.stack ); // Audit log: Booking creation failed await this.auditService.logFailure( AuditAction.BOOKING_CREATED, user.id, user.email, user.organizationId, error?.message || 'Unknown error', { resourceType: 'booking', metadata: { rateQuoteId: dto.rateQuoteId, }, } ); throw error; } } @Get(':id') @ApiOperation({ summary: 'Get booking by ID', description: 'Retrieve detailed information about a specific booking. Requires authentication.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)', example: '550e8400-e29b-41d4-a716-446655440000', }) @ApiResponse({ status: HttpStatus.OK, description: 'Booking details retrieved successfully', type: BookingResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiNotFoundResponse({ description: 'Booking not found', }) async getBooking( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`); const booking = await this.bookingRepository.findById(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } // Verify booking belongs to user's organization if (booking.organizationId !== user.organizationId) { throw new NotFoundException(`Booking ${id} not found`); } // Fetch rate quote const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (!rateQuote) { throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); } return BookingMapper.toDto(booking, rateQuote); } @Get('number/:bookingNumber') @ApiOperation({ summary: 'Get booking by booking number', description: 'Retrieve detailed information about a specific booking using its booking number. Requires authentication.', }) @ApiParam({ name: 'bookingNumber', description: 'Booking number', example: 'WCM-2025-ABC123', }) @ApiResponse({ status: HttpStatus.OK, description: 'Booking details retrieved successfully', type: BookingResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiNotFoundResponse({ description: 'Booking not found', }) async getBookingByNumber( @Param('bookingNumber') bookingNumber: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`); const bookingNumberVo = BookingNumber.fromString(bookingNumber); const booking = await this.bookingRepository.findByBookingNumber(bookingNumberVo); if (!booking) { throw new NotFoundException(`Booking ${bookingNumber} not found`); } // Verify booking belongs to user's organization if (booking.organizationId !== user.organizationId) { throw new NotFoundException(`Booking ${bookingNumber} not found`); } // Fetch rate quote const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (!rateQuote) { throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); } return BookingMapper.toDto(booking, rateQuote); } @Get() @ApiOperation({ summary: 'List bookings', description: "Retrieve a paginated list of bookings for the authenticated user's organization. Requires authentication.", }) @ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)', example: 1, }) @ApiQuery({ name: 'pageSize', required: false, description: 'Number of items per page', example: 20, }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status', enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'], }) @ApiResponse({ status: HttpStatus.OK, description: 'Bookings list retrieved successfully', type: BookingListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async listBookings( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, @Query('status') status: string | undefined, @CurrentUser() user: UserPayload ): Promise { this.logger.log( `[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}` ); // ADMIN: Fetch ALL bookings from database // Others: Fetch only bookings from their organization let bookings: any[]; if (user.role === 'ADMIN') { this.logger.log(`[ADMIN] Fetching ALL bookings from database`); bookings = await this.bookingRepository.findAll(); } else { this.logger.log(`[User] Fetching bookings from organization: ${user.organizationId}`); bookings = await this.bookingRepository.findByOrganization(user.organizationId); } // Filter by status if provided const filteredBookings = status ? bookings.filter((b: any) => b.status.value === status) : bookings; // Paginate const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedBookings = filteredBookings.slice(startIndex, endIndex); // Fetch rate quotes for all bookings const bookingsWithQuotes = await Promise.all( paginatedBookings.map(async (booking: any) => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); return { booking, rateQuote: rateQuote! }; }) ); // Convert to DTOs const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); const totalPages = Math.ceil(filteredBookings.length / pageSize); return { bookings: bookingDtos, total: filteredBookings.length, page, pageSize, totalPages, }; } @Get('search/fuzzy') @ApiOperation({ summary: 'Fuzzy search bookings', description: 'Search bookings using fuzzy matching. Tolerant to typos and partial matches. Searches across booking number, shipper, and consignee names.', }) @ApiQuery({ name: 'q', required: true, description: 'Search query (minimum 2 characters)', example: 'WCM-2025', }) @ApiQuery({ name: 'limit', required: false, description: 'Maximum number of results', example: 20, }) @ApiResponse({ status: HttpStatus.OK, description: 'Search results retrieved successfully', type: [BookingResponseDto], }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async fuzzySearch( @Query('q') searchTerm: string, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`); if (!searchTerm || searchTerm.length < 2) { return []; } // Perform fuzzy search const bookingOrms = await this.fuzzySearchService.search( searchTerm, user.organizationId, limit ); // Map ORM entities to domain and fetch rate quotes const bookingsWithQuotes = await Promise.all( bookingOrms.map(async bookingOrm => { const booking = await this.bookingRepository.findById(bookingOrm.id); const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); return { booking: booking!, rateQuote: rateQuote! }; }) ); // Convert to DTOs const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => BookingMapper.toDto(booking, rateQuote) ); this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`); return bookingDtos; } @Get('advanced/search') @ApiOperation({ summary: 'Advanced booking search with filtering', description: 'Search bookings with advanced filtering options including status, date ranges, carrier, ports, shipper/consignee. Supports sorting and pagination.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Filtered bookings retrieved successfully', type: BookingListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async advancedSearch( @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log( `[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}` ); // Fetch all bookings for organization let bookings = await this.bookingRepository.findByOrganization(user.organizationId); // Apply filters bookings = this.applyFilters(bookings, filter); // Sort bookings bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!); // Total count before pagination const total = bookings.length; // Paginate const startIndex = ((filter.page || 1) - 1) * (filter.pageSize || 20); const endIndex = startIndex + (filter.pageSize || 20); const paginatedBookings = bookings.slice(startIndex, endIndex); // Fetch rate quotes const bookingsWithQuotes = await Promise.all( paginatedBookings.map(async booking => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); return { booking, rateQuote: rateQuote! }; }) ); // Convert to DTOs const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); const totalPages = Math.ceil(total / (filter.pageSize || 20)); return { bookings: bookingDtos, total, page: filter.page || 1, pageSize: filter.pageSize || 20, totalPages, }; } @Post('export') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Export bookings to CSV/Excel/JSON', description: 'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.', }) @ApiProduces( 'text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/json' ) @ApiResponse({ status: HttpStatus.OK, description: 'Export file generated successfully', }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async exportBookings( @Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto, @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, @CurrentUser() user: UserPayload, @Res({ passthrough: true }) res: Response ): Promise { this.logger.log(`[User: ${user.email}] Exporting bookings to ${exportDto.format}`); let bookings: any[]; // If specific booking IDs provided, use those if (exportDto.bookingIds && exportDto.bookingIds.length > 0) { bookings = await Promise.all( exportDto.bookingIds.map(id => this.bookingRepository.findById(id)) ); bookings = bookings.filter(b => b !== null && b.organizationId === user.organizationId); } else { // Otherwise, use filter criteria bookings = await this.bookingRepository.findByOrganization(user.organizationId); bookings = this.applyFilters(bookings, filter); } // Fetch rate quotes const bookingsWithQuotes = await Promise.all( bookings.map(async booking => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); return { booking, rateQuote: rateQuote! }; }) ); // Generate export file const exportResult = await this.exportService.exportBookings( bookingsWithQuotes, exportDto.format, exportDto.fields ); // Set response headers res.set({ 'Content-Type': exportResult.contentType, 'Content-Disposition': `attachment; filename="${exportResult.filename}"`, }); // Audit log: Data exported await this.auditService.logSuccess( AuditAction.DATA_EXPORTED, user.id, user.email, user.organizationId, { resourceType: 'booking', metadata: { format: exportDto.format, bookingCount: bookings.length, fields: exportDto.fields?.join(', ') || 'all', filename: exportResult.filename, }, } ); return new StreamableFile(exportResult.buffer); } /** * Apply filters to bookings array */ private applyFilters(bookings: any[], filter: BookingFilterDto): any[] { let filtered = bookings; // Filter by status if (filter.status && filter.status.length > 0) { filtered = filtered.filter(b => filter.status!.includes(b.status.value)); } // Filter by search (booking number partial match) if (filter.search) { const searchLower = filter.search.toLowerCase(); filtered = filtered.filter(b => b.bookingNumber.value.toLowerCase().includes(searchLower)); } // Filter by shipper if (filter.shipper) { const shipperLower = filter.shipper.toLowerCase(); filtered = filtered.filter(b => b.shipper.name.toLowerCase().includes(shipperLower)); } // Filter by consignee if (filter.consignee) { const consigneeLower = filter.consignee.toLowerCase(); filtered = filtered.filter(b => b.consignee.name.toLowerCase().includes(consigneeLower)); } // Filter by creation date range if (filter.createdFrom) { const fromDate = new Date(filter.createdFrom); filtered = filtered.filter(b => b.createdAt >= fromDate); } if (filter.createdTo) { const toDate = new Date(filter.createdTo); filtered = filtered.filter(b => b.createdAt <= toDate); } return filtered; } /** * Sort bookings array */ private sortBookings(bookings: any[], sortBy: string, sortOrder: string): any[] { return [...bookings].sort((a, b) => { let aValue: any; let bValue: any; switch (sortBy) { case 'bookingNumber': aValue = a.bookingNumber.value; bValue = b.bookingNumber.value; break; case 'status': aValue = a.status.value; bValue = b.status.value; break; case 'createdAt': default: aValue = a.createdAt; bValue = b.createdAt; break; } if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; return 0; }); } }