import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity'; import { PortCode } from '@domain/value-objects/port-code.vo'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { NotificationRepository, NOTIFICATION_REPOSITORY, } from '@domain/ports/out/notification.repository'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; import { Notification, NotificationType, NotificationPriority, } from '@domain/entities/notification.entity'; import { CreateCsvBookingDto, CsvBookingResponseDto, CsvBookingDocumentDto, CsvBookingListResponseDto, CsvBookingStatsDto, } from '../dto/csv-booking.dto'; /** * CSV Booking Document (simple class for domain) */ class CsvBookingDocumentImpl { constructor( public readonly id: string, public readonly type: DocumentType, public readonly fileName: string, public readonly filePath: string, public readonly mimeType: string, public readonly size: number, public readonly uploadedAt: Date ) {} } /** * CSV Booking Service * * Handles business logic for CSV-based booking requests */ @Injectable() export class CsvBookingService { private readonly logger = new Logger(CsvBookingService.name); constructor( private readonly csvBookingRepository: TypeOrmCsvBookingRepository, @Inject(NOTIFICATION_REPOSITORY) private readonly notificationRepository: NotificationRepository, @Inject(EMAIL_PORT) private readonly emailAdapter: EmailPort, @Inject(STORAGE_PORT) private readonly storageAdapter: StoragePort ) {} /** * Create a new CSV booking request */ async createBooking( dto: CreateCsvBookingDto, files: Express.Multer.File[], userId: string, organizationId: string ): Promise { this.logger.log(`Creating CSV booking for user ${userId}`); // Validate minimum document requirement if (!files || files.length === 0) { throw new BadRequestException('At least one document is required'); } // Generate unique confirmation token const confirmationToken = uuidv4(); const bookingId = uuidv4(); // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); // Create domain entity const booking = new CsvBooking( bookingId, userId, organizationId, dto.carrierName, dto.carrierEmail, PortCode.create(dto.origin), PortCode.create(dto.destination), dto.volumeCBM, dto.weightKG, dto.palletCount, dto.priceUSD, dto.priceEUR, dto.primaryCurrency, dto.transitDays, dto.containerType, CsvBookingStatus.PENDING, documents, confirmationToken, new Date(), undefined, dto.notes ); // Save to database const savedBooking = await this.csvBookingRepository.create(booking); this.logger.log(`CSV booking created with ID: ${bookingId}`); // Send email to carrier and WAIT for confirmation // The button waits for the email to be sent before responding try { await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { bookingId, origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, palletCount: dto.palletCount, priceUSD: dto.priceUSD, priceEUR: dto.priceEUR, primaryCurrency: dto.primaryCurrency, transitDays: dto.transitDays, containerType: dto.containerType, documents: documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), confirmationToken, }); this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); // Continue even if email fails - booking is already saved } // Create notification for user try { const notification = Notification.create({ id: uuidv4(), userId, organizationId, type: NotificationType.CSV_BOOKING_REQUEST_SENT, priority: NotificationPriority.MEDIUM, title: 'Booking Request Sent', message: `Your booking request to ${dto.carrierName} for ${dto.origin} → ${dto.destination} has been sent successfully.`, metadata: { bookingId, carrierName: dto.carrierName }, }); await this.notificationRepository.save(notification); this.logger.log(`Notification created for user ${userId}`); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); // Continue even if notification fails } return this.toResponseDto(savedBooking); } /** * Get booking by ID * Accessible by: booking owner OR assigned carrier */ async getBookingById( id: string, userId: string, carrierId?: string ): Promise { const booking = await this.csvBookingRepository.findById(id); if (!booking) { throw new NotFoundException(`Booking with ID ${id} not found`); } // Get ORM booking to access carrierId const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id }, }); // Verify user owns this booking OR is the assigned carrier const isOwner = booking.userId === userId; const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId; if (!isOwner && !isAssignedCarrier) { throw new NotFoundException(`Booking with ID ${id} not found`); } return this.toResponseDto(booking); } /** * Get booking by confirmation token (public endpoint) */ async getBookingByToken(token: string): Promise { const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { throw new NotFoundException(`Booking with token ${token} not found`); } return this.toResponseDto(booking); } /** * Accept a booking request */ async acceptBooking(token: string): Promise { this.logger.log(`Accepting booking with token: ${token}`); const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { throw new NotFoundException('Booking not found'); } // Accept the booking (domain logic validates status) booking.accept(); // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); // Create notification for user try { const notification = Notification.create({ id: uuidv4(), userId: booking.userId, organizationId: booking.organizationId, type: NotificationType.CSV_BOOKING_ACCEPTED, priority: NotificationPriority.HIGH, title: 'Booking Request Accepted', message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been accepted!`, metadata: { bookingId: booking.id, carrierName: booking.carrierName }, }); await this.notificationRepository.save(notification); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); } return this.toResponseDto(updatedBooking); } /** * Reject a booking request */ async rejectBooking(token: string, reason?: string): Promise { this.logger.log(`Rejecting booking with token: ${token}`); const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { throw new NotFoundException('Booking not found'); } // Reject the booking (domain logic validates status) booking.reject(reason); // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} rejected`); // Create notification for user try { const notification = Notification.create({ id: uuidv4(), userId: booking.userId, organizationId: booking.organizationId, type: NotificationType.CSV_BOOKING_REJECTED, priority: NotificationPriority.HIGH, title: 'Booking Request Rejected', message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} was rejected. ${reason ? `Reason: ${reason}` : ''}`, metadata: { bookingId: booking.id, carrierName: booking.carrierName, rejectionReason: reason, }, }); await this.notificationRepository.save(notification); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); } return this.toResponseDto(updatedBooking); } /** * Cancel a booking (user action) */ async cancelBooking(id: string, userId: string): Promise { this.logger.log(`Cancelling booking ${id} by user ${userId}`); const booking = await this.csvBookingRepository.findById(id); if (!booking) { throw new NotFoundException('Booking not found'); } // Verify user owns this booking if (booking.userId !== userId) { throw new NotFoundException('Booking not found'); } // Cancel the booking (domain logic validates status) booking.cancel(); // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${id} cancelled`); return this.toResponseDto(updatedBooking); } /** * Get bookings for a user (paginated) */ async getUserBookings( userId: string, page: number = 1, limit: number = 10 ): Promise { const bookings = await this.csvBookingRepository.findByUserId(userId); // Simple pagination (in-memory) const start = (page - 1) * limit; const end = start + limit; const paginatedBookings = bookings.slice(start, end); return { bookings: paginatedBookings.map(b => this.toResponseDto(b)), total: bookings.length, page, limit, totalPages: Math.ceil(bookings.length / limit), }; } /** * Get bookings for an organization (paginated) */ async getOrganizationBookings( organizationId: string, page: number = 1, limit: number = 10 ): Promise { const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); // Simple pagination (in-memory) const start = (page - 1) * limit; const end = start + limit; const paginatedBookings = bookings.slice(start, end); return { bookings: paginatedBookings.map(b => this.toResponseDto(b)), total: bookings.length, page, limit, totalPages: Math.ceil(bookings.length / limit), }; } /** * Get booking statistics for user */ async getUserStats(userId: string): Promise { const stats = await this.csvBookingRepository.countByStatusForUser(userId); return { pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, cancelled: stats[CsvBookingStatus.CANCELLED] || 0, total: Object.values(stats).reduce((sum, count) => sum + count, 0), }; } /** * Get booking statistics for organization */ async getOrganizationStats(organizationId: string): Promise { const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); return { pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, cancelled: stats[CsvBookingStatus.CANCELLED] || 0, total: Object.values(stats).reduce((sum, count) => sum + count, 0), }; } /** * Upload documents to S3 and create document entities */ private async uploadDocuments( files: Express.Multer.File[], bookingId: string ): Promise { const bucket = 'xpeditis-documents'; // You can make this configurable const documents: CsvBookingDocumentImpl[] = []; for (const file of files) { const documentId = uuidv4(); const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`; // Upload to S3 const uploadResult = await this.storageAdapter.upload({ bucket, key: fileKey, body: file.buffer, contentType: file.mimetype, }); // Determine document type from filename or default to OTHER const documentType = this.inferDocumentType(file.originalname); const document = new CsvBookingDocumentImpl( documentId, documentType, file.originalname, uploadResult.url, file.mimetype, file.size, new Date() ); documents.push(document); } this.logger.log(`Uploaded ${documents.length} documents for booking ${bookingId}`); return documents; } /** * Link a booking to a carrier profile */ async linkBookingToCarrier(bookingId: string, carrierId: string): Promise { this.logger.log(`Linking booking ${bookingId} to carrier ${carrierId}`); const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking not found: ${bookingId}`); } // Update the booking with carrier ID (using the ORM repository directly) const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { ormBooking.carrierId = carrierId; await this.csvBookingRepository['repository'].save(ormBooking); this.logger.log(`Successfully linked booking ${bookingId} to carrier ${carrierId}`); } } /** * Add documents to an existing booking */ async addDocuments( bookingId: string, files: Express.Multer.File[], userId: string ): Promise<{ success: boolean; message: string; documentsAdded: number }> { this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`); const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } // Verify user owns this booking if (booking.userId !== userId) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } // Verify booking is still pending if (booking.status !== CsvBookingStatus.PENDING) { throw new BadRequestException('Cannot add documents to a booking that is not pending'); } // Upload new documents const newDocuments = await this.uploadDocuments(files, bookingId); // Add documents to booking const updatedDocuments = [...booking.documents, ...newDocuments]; // Update booking in database using ORM repository directly const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { ormBooking.documents = updatedDocuments.map(doc => ({ id: doc.id, type: doc.type, fileName: doc.fileName, filePath: doc.filePath, mimeType: doc.mimeType, size: doc.size, uploadedAt: doc.uploadedAt, })); await this.csvBookingRepository['repository'].save(ormBooking); } this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`); return { success: true, message: 'Documents added successfully', documentsAdded: newDocuments.length, }; } /** * Delete a document from a booking */ async deleteDocument( bookingId: string, documentId: string, userId: string ): Promise<{ success: boolean; message: string }> { this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`); const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } // Verify user owns this booking if (booking.userId !== userId) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } // Verify booking is still pending if (booking.status !== CsvBookingStatus.PENDING) { throw new BadRequestException('Cannot delete documents from a booking that is not pending'); } // Find the document const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document with ID ${documentId} not found`); } // Ensure at least one document remains if (booking.documents.length <= 1) { throw new BadRequestException( 'Cannot delete the last document. At least one document is required.' ); } // Get the document to delete (for potential S3 cleanup - currently kept for audit) const _documentToDelete = booking.documents[documentIndex]; // Remove document from array const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); // Update booking in database using ORM repository directly const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { ormBooking.documents = updatedDocuments.map(doc => ({ id: doc.id, type: doc.type, fileName: doc.fileName, filePath: doc.filePath, mimeType: doc.mimeType, size: doc.size, uploadedAt: doc.uploadedAt, })); await this.csvBookingRepository['repository'].save(ormBooking); } // Optionally delete from S3 (commented out for safety - keep files for audit) // try { // await this.storageAdapter.delete({ // bucket: 'xpeditis-documents', // key: documentToDelete.filePath, // }); // } catch (error) { // this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`); // } this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`); return { success: true, message: 'Document deleted successfully', }; } /** * Infer document type from filename */ private inferDocumentType(filename: string): DocumentType { const lowerFilename = filename.toLowerCase(); if ( lowerFilename.includes('bill') || lowerFilename.includes('bol') || lowerFilename.includes('lading') ) { return DocumentType.BILL_OF_LADING; } if (lowerFilename.includes('packing') || lowerFilename.includes('list')) { return DocumentType.PACKING_LIST; } if (lowerFilename.includes('invoice') || lowerFilename.includes('commercial')) { return DocumentType.COMMERCIAL_INVOICE; } if (lowerFilename.includes('certificate') || lowerFilename.includes('origin')) { return DocumentType.CERTIFICATE_OF_ORIGIN; } return DocumentType.OTHER; } /** * Convert domain entity to response DTO */ private toResponseDto(booking: CsvBooking): CsvBookingResponseDto { const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; return { id: booking.id, userId: booking.userId, organizationId: booking.organizationId, carrierName: booking.carrierName, carrierEmail: booking.carrierEmail, origin: booking.origin.getValue(), destination: booking.destination.getValue(), volumeCBM: booking.volumeCBM, weightKG: booking.weightKG, palletCount: booking.palletCount, priceUSD: booking.priceUSD, priceEUR: booking.priceEUR, primaryCurrency: booking.primaryCurrency, transitDays: booking.transitDays, containerType: booking.containerType, status: booking.status, documents: booking.documents.map(this.toDocumentDto), confirmationToken: booking.confirmationToken, requestedAt: booking.requestedAt, respondedAt: booking.respondedAt || null, notes: booking.notes, rejectionReason: booking.rejectionReason, routeDescription: booking.getRouteDescription(), isExpired: booking.isExpired(), price: booking.getPriceInCurrency(primaryCurrency), }; } /** * Convert domain document to DTO */ private toDocumentDto(document: any): CsvBookingDocumentDto { return { id: document.id, type: document.type, fileName: document.fileName, filePath: document.filePath, mimeType: document.mimeType, size: document.size, uploadedAt: document.uploadedAt, }; } }