import { Injectable, Logger, NotFoundException, BadRequestException, Inject, UnauthorizedException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; 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 { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { Notification, NotificationType, NotificationPriority, } from '@domain/entities/notification.entity'; import { CreateCsvBookingDto, CsvBookingResponseDto, CsvBookingDocumentDto, CsvBookingListResponseDto, CsvBookingStatsDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { SubscriptionService } from './subscription.service'; /** * 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, @Inject(STRIPE_PORT) private readonly stripeAdapter: StripePort, private readonly subscriptionService: SubscriptionService, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository ) {} /** * Generate a unique booking number * Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9) */ private generateBookingNumber(): string { const year = new Date().getFullYear(); const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity let code = ''; for (let i = 0; i < 6; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)); } return `XPD-${year}-${code}`; } /** * Extract the password from booking number (last 6 characters) */ private extractPasswordFromBookingNumber(bookingNumber: string): string { return bookingNumber.split('-').pop() || bookingNumber.slice(-6); } /** * 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 and booking number const confirmationToken = uuidv4(); const bookingId = uuidv4(); const bookingNumber = this.generateBookingNumber(); const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber); // Hash the password for storage const passwordHash = await argon2.hash(documentPassword); // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); // Calculate commission based on organization's subscription plan let commissionRate = 5; // default Bronze let commissionAmountEur = 0; try { const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); commissionRate = subscription.plan.commissionRatePercent; } catch (error: any) { this.logger.error(`Failed to get subscription for commission: ${error?.message}`); } commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100; // Create domain entity in PENDING_PAYMENT status (no email sent yet) 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_PAYMENT, documents, confirmationToken, new Date(), undefined, dto.notes, undefined, bookingNumber, commissionRate, commissionAmountEur ); // Save to database const savedBooking = await this.csvBookingRepository.create(booking); // Update ORM entity with booking number and password hash const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { ormBooking.bookingNumber = bookingNumber; ormBooking.passwordHash = passwordHash; await this.csvBookingRepository['repository'].save(ormBooking); } this.logger.log( `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€` ); // NO email sent to carrier yet - will be sent after commission payment // NO notification yet - will be created after payment confirmation return this.toResponseDto(savedBooking); } /** * Create a Stripe Checkout session for commission payment */ async createCommissionPayment( bookingId: string, userId: string, userEmail: string, frontendUrl: string ): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.userId !== userId) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { throw new BadRequestException( `Booking is not awaiting payment. Current status: ${booking.status}` ); } const commissionAmountEur = booking.commissionAmountEur || 0; if (commissionAmountEur <= 0) { throw new BadRequestException('Commission amount is invalid'); } const amountCents = Math.round(commissionAmountEur * 100); const result = await this.stripeAdapter.createCommissionCheckout({ bookingId: booking.id, amountCents, currency: 'eur', customerEmail: userEmail, organizationId: booking.organizationId, bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`, successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`, }); this.logger.log( `Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR` ); return { sessionUrl: result.sessionUrl, sessionId: result.sessionId, commissionAmountEur, }; } /** * Confirm commission payment and activate booking * Called after Stripe redirect with session_id */ async confirmCommissionPayment( bookingId: string, sessionId: string, userId: string ): Promise { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.userId !== userId) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { // Already confirmed - return current state if (booking.status === CsvBookingStatus.PENDING) { return this.toResponseDto(booking); } throw new BadRequestException( `Booking is not awaiting payment. Current status: ${booking.status}` ); } // Verify payment with Stripe const session = await this.stripeAdapter.getCheckoutSession(sessionId); if (!session || session.status !== 'complete') { throw new BadRequestException('Payment has not been completed'); } // Verify the session is for this booking if (session.metadata?.bookingId !== bookingId) { throw new BadRequestException('Payment session does not match this booking'); } // Transition to PENDING booking.markPaymentCompleted(); booking.stripePaymentIntentId = sessionId; // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`); // Get ORM entity for booking number const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); const bookingNumber = ormBooking?.bookingNumber; const documentPassword = bookingNumber ? this.extractPasswordFromBookingNumber(bookingNumber) : undefined; // NOW send email to carrier try { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { bookingId: booking.id, bookingNumber: bookingNumber || '', documentPassword: documentPassword || '', 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, documents: booking.documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), confirmationToken: booking.confirmationToken, notes: booking.notes, }); this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); } // Create notification for user try { const notification = Notification.create({ id: uuidv4(), userId: booking.userId, organizationId: booking.organizationId, type: NotificationType.CSV_BOOKING_REQUEST_SENT, priority: NotificationPriority.MEDIUM, title: 'Booking Request Sent', message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, 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); } /** * Declare bank transfer — user confirms they have sent the wire transfer * Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER * Sends an email notification to all ADMIN users */ async declareBankTransfer(bookingId: string, userId: string): Promise { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.userId !== userId) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { throw new BadRequestException( `Booking is not awaiting payment. Current status: ${booking.status}` ); } // Get booking number before update const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase(); booking.markBankTransferDeclared(); const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log( `Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER` ); // Send email to all ADMIN users try { const allUsers = await this.userRepository.findAll(); const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email); if (adminEmails.length > 0) { const commissionAmount = booking.commissionAmountEur ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format( booking.commissionAmountEur ) : 'N/A'; await this.emailAdapter.send({ to: adminEmails, subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`, html: `

Nouveau virement à valider

Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :

Numéro de booking ${bookingNumber}
Transporteur ${booking.carrierName}
Trajet ${booking.getRouteDescription()}
Montant commission ${commissionAmount}

Rendez-vous dans la console d'administration pour valider ce virement et activer le booking.

Voir les bookings en attente
`, }); this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); } } catch (error: any) { this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack); } // In-app notification for the user try { const notification = Notification.create({ id: uuidv4(), userId: booking.userId, organizationId: booking.organizationId, type: NotificationType.BOOKING_UPDATED, priority: NotificationPriority.MEDIUM, title: 'Virement déclaré', message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`, metadata: { bookingId: booking.id }, }); await this.notificationRepository.save(notification); } catch (error: any) { this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); } return this.toResponseDto(updatedBooking); } /** * Resend carrier email for a booking (admin action) * Works regardless of payment status — useful for retrying failed emails or testing without Stripe. */ async resendCarrierEmail(bookingId: string): Promise { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); const bookingNumber = ormBooking?.bookingNumber; const documentPassword = bookingNumber ? this.extractPasswordFromBookingNumber(bookingNumber) : undefined; await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { bookingId: booking.id, bookingNumber: bookingNumber || '', documentPassword: documentPassword || '', 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, documents: booking.documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), confirmationToken: booking.confirmationToken, notes: booking.notes, }); this.logger.log( `[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}` ); } /** * Admin validates bank transfer — confirms receipt and activates booking * Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier */ async validateBankTransfer(bookingId: string): Promise { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { throw new BadRequestException( `Booking is not awaiting bank transfer validation. Current status: ${booking.status}` ); } booking.markBankTransferValidated(); const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`); // Get booking number for email const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); const bookingNumber = ormBooking?.bookingNumber; const documentPassword = bookingNumber ? this.extractPasswordFromBookingNumber(bookingNumber) : undefined; // Send email to carrier try { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { bookingId: booking.id, bookingNumber: bookingNumber || '', documentPassword: documentPassword || '', 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, documents: booking.documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), confirmationToken: booking.confirmationToken, notes: booking.notes, }); this.logger.log( `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` ); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); } // In-app notification for the user try { const notification = Notification.create({ id: uuidv4(), userId: booking.userId, organizationId: booking.organizationId, type: NotificationType.BOOKING_CONFIRMED, priority: NotificationPriority.HIGH, title: 'Virement validé — Booking activé', message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`, metadata: { bookingId: booking.id }, }); await this.notificationRepository.save(notification); } catch (error: any) { this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); } return this.toResponseDto(updatedBooking); } /** * 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); } /** * Verify password and get booking documents for carrier (public endpoint) * Only accessible for ACCEPTED bookings with correct password */ async getDocumentsForCarrier( token: string, password?: string ): Promise { this.logger.log(`Getting documents for carrier with token: ${token}`); // Get ORM entity to access passwordHash const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { confirmationToken: token }, }); if (!ormBooking) { throw new NotFoundException('Réservation introuvable'); } // Only allow access for ACCEPTED bookings if (ormBooking.status !== 'ACCEPTED') { throw new BadRequestException("Cette réservation n'a pas encore été acceptée"); } // Check if password protection is enabled for this booking if (ormBooking.passwordHash) { if (!password) { throw new UnauthorizedException('Mot de passe requis pour accéder aux documents'); } const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password); if (!isPasswordValid) { throw new UnauthorizedException('Mot de passe incorrect'); } } // Get domain booking for business logic const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { throw new NotFoundException('Réservation introuvable'); } // Generate signed URLs for all documents const documentsWithUrls = await Promise.all( booking.documents.map(async doc => { const signedUrl = await this.generateSignedUrlForDocument(doc.filePath); return { id: doc.id, type: doc.type, fileName: doc.fileName, mimeType: doc.mimeType, size: doc.size, downloadUrl: signedUrl, }; }) ); const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; return { booking: { id: booking.id, bookingNumber: ormBooking.bookingNumber || undefined, carrierName: booking.carrierName, origin: booking.origin.getValue(), destination: booking.destination.getValue(), routeDescription: booking.getRouteDescription(), volumeCBM: booking.volumeCBM, weightKG: booking.weightKG, palletCount: booking.palletCount, price: booking.getPriceInCurrency(primaryCurrency), currency: primaryCurrency, transitDays: booking.transitDays, containerType: booking.containerType, acceptedAt: booking.respondedAt!, }, documents: documentsWithUrls, }; } /** * Check if a booking requires password for document access */ async checkDocumentAccessRequirements( token: string ): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> { const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { confirmationToken: token }, }); if (!ormBooking) { throw new NotFoundException('Réservation introuvable'); } return { requiresPassword: !!ormBooking.passwordHash, bookingNumber: ormBooking.bookingNumber || undefined, status: ormBooking.status, }; } /** * Generate signed URL for a document file path */ private async generateSignedUrlForDocument(filePath: string): Promise { const bucket = 'xpeditis-documents'; // Extract key from the file path let key = filePath; if (filePath.includes('xpeditis-documents/')) { key = filePath.split('xpeditis-documents/')[1]; } else if (filePath.startsWith('http')) { const url = new URL(filePath); key = url.pathname.replace(/^\//, ''); if (key.startsWith('xpeditis-documents/')) { key = key.replace('xpeditis-documents/', ''); } } // Generate signed URL with 1 hour expiration const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600); return signedUrl; } /** * 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'); } // Get ORM entity for bookingNumber const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { confirmationToken: token }, }); // Accept the booking (domain logic validates status) booking.accept(); // Apply commission based on organization's subscription plan try { const subscription = await this.subscriptionService.getOrCreateSubscription( booking.organizationId ); const commissionRate = subscription.plan.commissionRatePercent; const baseAmountEur = booking.priceEUR; booking.applyCommission(commissionRate, baseAmountEur); this.logger.log( `Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€` ); } catch (error: any) { this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack); } // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); // Extract password from booking number for the email const bookingNumber = ormBooking?.bookingNumber; const documentPassword = bookingNumber ? this.extractPasswordFromBookingNumber(bookingNumber) : undefined; // Send document access email to carrier try { await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, { carrierName: booking.carrierName, bookingId: booking.id, bookingNumber: bookingNumber || undefined, documentPassword: documentPassword, origin: booking.origin.getValue(), destination: booking.destination.getValue(), volumeCBM: booking.volumeCBM, weightKG: booking.weightKG, documentCount: booking.documents.length, confirmationToken: booking.confirmationToken, }); this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack); } // 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 { pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, 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 { pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, 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`); } // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings if ( booking.status !== CsvBookingStatus.PENDING_PAYMENT && booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED ) { throw new BadRequestException( 'Cannot add documents to a booking that is rejected or cancelled' ); } // 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}`); // If booking is ACCEPTED, notify carrier about new documents if (booking.status === CsvBookingStatus.ACCEPTED) { try { await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, { carrierName: booking.carrierName, bookingId: booking.id, origin: booking.origin.getValue(), destination: booking.destination.getValue(), newDocumentsCount: newDocuments.length, totalDocumentsCount: updatedDocuments.length, confirmationToken: booking.confirmationToken, }); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { this.logger.error( `Failed to send new documents notification: ${error?.message}`, error?.stack ); } } 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 or awaiting payment if ( booking.status !== CsvBookingStatus.PENDING_PAYMENT && 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', }; } /** * Replace a document in an existing booking */ async replaceDocument( bookingId: string, documentId: string, file: Express.Multer.File, userId: string ): Promise<{ success: boolean; message: string; newDocument: any }> { this.logger.log(`Replacing document ${documentId} in 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`); } // Find the document to replace const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document with ID ${documentId} not found`); } // Upload the new document const newDocuments = await this.uploadDocuments([file], bookingId); const newDocument = newDocuments[0]; // Replace the document in the array const updatedDocuments = [...booking.documents]; updatedDocuments[documentIndex] = newDocument; // 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( `Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}` ); return { success: true, message: 'Document replaced successfully', newDocument: { id: newDocument.id, type: newDocument.type, fileName: newDocument.fileName, filePath: newDocument.filePath, mimeType: newDocument.mimeType, size: newDocument.size, uploadedAt: newDocument.uploadedAt, }, }; } /** * 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, bookingNumber: booking.bookingNumber, 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), commissionRate: booking.commissionRate, commissionAmountEur: booking.commissionAmountEur, }; } /** * 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, }; } }