import { Controller, Post, Get, Patch, Delete, Body, Param, Query, UseGuards, UseInterceptors, UploadedFiles, Request, BadRequestException, ParseIntPipe, DefaultValuePipe, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBody, ApiBearerAuth, ApiQuery, ApiParam, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; import { CreateCsvBookingDto, CsvBookingResponseDto, CsvBookingListResponseDto, CsvBookingStatsDto, } from '../dto/csv-booking.dto'; /** * CSV Bookings Controller * * Handles HTTP requests for CSV-based booking requests * * IMPORTANT: Route order matters in NestJS! * Static routes MUST come BEFORE parameterized routes. * Otherwise, `:id` will capture "stats", "organization", etc. */ @ApiTags('CSV Bookings') @Controller('csv-bookings') export class CsvBookingsController { constructor(private readonly csvBookingService: CsvBookingService) {} // ============================================================================ // STATIC ROUTES (must come FIRST) // ============================================================================ /** * Create a new CSV booking request * * POST /api/v1/csv-bookings */ @Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseInterceptors(FilesInterceptor('documents', 10)) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new CSV booking request', description: 'Creates a new booking request from CSV rate selection. Uploads documents, sends email to carrier, and creates a notification for the user.', }) @ApiBody({ schema: { type: 'object', required: [ 'carrierName', 'carrierEmail', 'origin', 'destination', 'volumeCBM', 'weightKG', 'palletCount', 'priceUSD', 'priceEUR', 'primaryCurrency', 'transitDays', 'containerType', ], properties: { carrierName: { type: 'string', example: 'SSC Consolidation' }, carrierEmail: { type: 'string', format: 'email', example: 'bookings@sscconsolidation.com' }, origin: { type: 'string', example: 'NLRTM' }, destination: { type: 'string', example: 'USNYC' }, volumeCBM: { type: 'number', example: 25.5 }, weightKG: { type: 'number', example: 3500 }, palletCount: { type: 'number', example: 10 }, priceUSD: { type: 'number', example: 1850.5 }, priceEUR: { type: 'number', example: 1665.45 }, primaryCurrency: { type: 'string', enum: ['USD', 'EUR'], example: 'USD' }, transitDays: { type: 'number', example: 28 }, containerType: { type: 'string', example: 'LCL' }, notes: { type: 'string', example: 'Handle with care' }, documents: { type: 'array', items: { type: 'string', format: 'binary' }, description: 'Shipping documents (Bill of Lading, Packing List, Invoice, etc.)', }, }, }, }) @ApiResponse({ status: 201, description: 'Booking created successfully', type: CsvBookingResponseDto, }) @ApiResponse({ status: 400, description: 'Invalid request data or missing documents' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async createBooking( @Body() dto: CreateCsvBookingDto, @UploadedFiles() files: Express.Multer.File[], @Request() req: any ): Promise { // Debug: Log request details console.log('=== CSV Booking Request Debug ==='); console.log('req.user:', req.user); console.log('req.body:', req.body); console.log('dto:', dto); console.log('files:', files?.length); console.log('================================'); if (!files || files.length === 0) { throw new BadRequestException('At least one document is required'); } // Validate user authentication if (!req.user || !req.user.id) { throw new BadRequestException('User authentication failed - no user info in request'); } if (!req.user.organizationId) { throw new BadRequestException('Organization ID is required'); } const userId = req.user.id; const organizationId = req.user.organizationId; // Convert string values to numbers (multipart/form-data sends everything as strings) const sanitizedDto: CreateCsvBookingDto = { ...dto, volumeCBM: typeof dto.volumeCBM === 'string' ? parseFloat(dto.volumeCBM) : dto.volumeCBM, weightKG: typeof dto.weightKG === 'string' ? parseFloat(dto.weightKG) : dto.weightKG, palletCount: typeof dto.palletCount === 'string' ? parseInt(dto.palletCount, 10) : dto.palletCount, priceUSD: typeof dto.priceUSD === 'string' ? parseFloat(dto.priceUSD) : dto.priceUSD, priceEUR: typeof dto.priceEUR === 'string' ? parseFloat(dto.priceEUR) : dto.priceEUR, transitDays: typeof dto.transitDays === 'string' ? parseInt(dto.transitDays, 10) : dto.transitDays, }; return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId); } /** * Get current user's bookings (paginated) * * GET /api/v1/csv-bookings */ @Get() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get user bookings', description: 'Retrieve all bookings for the authenticated user with pagination.', }) @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) @ApiResponse({ status: 200, description: 'Bookings retrieved successfully', type: CsvBookingListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getUserBookings( @Request() req: any, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number ): Promise { const userId = req.user.id; return await this.csvBookingService.getUserBookings(userId, page, limit); } /** * Get booking statistics for user * * GET /api/v1/csv-bookings/stats/me */ @Get('stats/me') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get user booking statistics', description: 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).', }) @ApiResponse({ status: 200, description: 'Statistics retrieved successfully', type: CsvBookingStatsDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getUserStats(@Request() req: any): Promise { const userId = req.user.id; return await this.csvBookingService.getUserStats(userId); } /** * Get organization booking statistics * * GET /api/v1/csv-bookings/stats/organization */ @Get('stats/organization') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get organization booking statistics', description: "Get aggregated statistics for the user's organization. For managers/admins.", }) @ApiResponse({ status: 200, description: 'Statistics retrieved successfully', type: CsvBookingStatsDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getOrganizationStats(@Request() req: any): Promise { const organizationId = req.user.organizationId; return await this.csvBookingService.getOrganizationStats(organizationId); } /** * Get organization bookings (for managers/admins) * * GET /api/v1/csv-bookings/organization/all */ @Get('organization/all') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get organization bookings', description: "Retrieve all bookings for the user's organization with pagination. For managers/admins.", }) @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) @ApiResponse({ status: 200, description: 'Organization bookings retrieved successfully', type: CsvBookingListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getOrganizationBookings( @Request() req: any, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number ): Promise { const organizationId = req.user.organizationId; return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); } /** * Accept a booking request (PUBLIC - token-based) * * GET /api/v1/csv-bookings/accept/:token */ @Public() @Get('accept/:token') @ApiOperation({ summary: 'Accept booking request (public)', description: 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.', }) @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiResponse({ status: 200, description: 'Booking accepted successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ status: 400, description: 'Booking cannot be accepted (invalid status or expired)', }) async acceptBooking(@Param('token') token: string) { // Accept the booking const booking = await this.csvBookingService.acceptBooking(token); // Return simple success response return { success: true, bookingId: booking.id, action: 'accepted', }; } /** * Reject a booking request (PUBLIC - token-based) * * GET /api/v1/csv-bookings/reject/:token */ @Public() @Get('reject/:token') @ApiOperation({ summary: 'Reject booking request (public)', description: 'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.', }) @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiQuery({ name: 'reason', required: false, description: 'Rejection reason', example: 'No capacity available', }) @ApiResponse({ status: 200, description: 'Booking rejected successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ status: 400, description: 'Booking cannot be rejected (invalid status or expired)', }) async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) { // Reject the booking const booking = await this.csvBookingService.rejectBooking(token, reason); // Return simple success response return { success: true, bookingId: booking.id, action: 'rejected', reason: reason || null, }; } // ============================================================================ // PARAMETERIZED ROUTES (must come LAST) // ============================================================================ /** * Get a booking by ID * * GET /api/v1/csv-bookings/:id * * IMPORTANT: This route MUST be after all static GET routes * Otherwise it will capture "stats", "organization", etc. */ @Get(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get booking by ID', description: 'Retrieve a specific CSV booking by its ID. Only accessible by the booking owner.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ status: 200, description: 'Booking retrieved successfully', type: CsvBookingResponseDto, }) @ApiResponse({ status: 404, description: 'Booking not found' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getBooking(@Param('id') id: string, @Request() req: any): Promise { const userId = req.user.id; const carrierId = req.user.carrierId; // May be undefined if not a carrier return await this.csvBookingService.getBookingById(id, userId, carrierId); } /** * Cancel a booking (user action) * * PATCH /api/v1/csv-bookings/:id/cancel */ @Patch(':id/cancel') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Cancel booking', description: 'Cancel a pending booking. Only accessible by the booking owner.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ status: 200, description: 'Booking cancelled successfully', type: CsvBookingResponseDto, }) @ApiResponse({ status: 404, description: 'Booking not found' }) @ApiResponse({ status: 400, description: 'Booking cannot be cancelled (already accepted)' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async cancelBooking( @Param('id') id: string, @Request() req: any ): Promise { const userId = req.user.id; return await this.csvBookingService.cancelBooking(id, userId); } /** * Add documents to an existing booking * * POST /api/v1/csv-bookings/:id/documents */ @Post(':id/documents') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseInterceptors(FilesInterceptor('documents', 10)) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Add documents to an existing booking', description: 'Upload additional documents to a pending booking. Only the booking owner can add documents.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiBody({ schema: { type: 'object', properties: { documents: { type: 'array', items: { type: 'string', format: 'binary' }, description: 'Documents to add (max 10 files)', }, }, }, }) @ApiResponse({ status: 200, description: 'Documents added successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, message: { type: 'string', example: 'Documents added successfully' }, documentsAdded: { type: 'number', example: 2 }, }, }, }) @ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Booking not found' }) async addDocuments( @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[], @Request() req: any ) { if (!files || files.length === 0) { throw new BadRequestException('At least one document is required'); } const userId = req.user.id; return await this.csvBookingService.addDocuments(id, files, userId); } /** * Replace a document in a booking * * PUT /api/v1/csv-bookings/:bookingId/documents/:documentId */ @Patch(':bookingId/documents/:documentId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseInterceptors(FilesInterceptor('document', 1)) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Replace a document in a booking', description: 'Replace an existing document with a new one. Only the booking owner can replace documents.', }) @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) @ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' }) @ApiBody({ schema: { type: 'object', properties: { document: { type: 'string', format: 'binary', description: 'New document file to replace the existing one', }, }, }, }) @ApiResponse({ status: 200, description: 'Document replaced successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, message: { type: 'string', example: 'Document replaced successfully' }, newDocument: { type: 'object', properties: { id: { type: 'string' }, type: { type: 'string' }, fileName: { type: 'string' }, filePath: { type: 'string' }, mimeType: { type: 'string' }, size: { type: 'number' }, uploadedAt: { type: 'string', format: 'date-time' }, }, }, }, }, }) @ApiResponse({ status: 400, description: 'Invalid request - missing file' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Booking or document not found' }) async replaceDocument( @Param('bookingId') bookingId: string, @Param('documentId') documentId: string, @UploadedFiles() files: Express.Multer.File[], @Request() req: any ) { if (!files || files.length === 0) { throw new BadRequestException('A document file is required'); } const userId = req.user.id; return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId); } /** * Delete a document from a booking * * DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId */ @Delete(':bookingId/documents/:documentId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Delete a document from a booking', description: 'Remove a document from a pending booking. Only the booking owner can delete documents.', }) @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) @ApiParam({ name: 'documentId', description: 'Document ID (UUID)' }) @ApiResponse({ status: 200, description: 'Document deleted successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, message: { type: 'string', example: 'Document deleted successfully' }, }, }, }) @ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Booking or document not found' }) async deleteDocument( @Param('bookingId') bookingId: string, @Param('documentId') documentId: string, @Request() req: any ) { const userId = req.user.id; return await this.csvBookingService.deleteDocument(bookingId, documentId, userId); } }