import { Injectable, Logger, NotFoundException, BadRequestException, Inject, UnauthorizedException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; 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, UpdateCsvBookingDetailsDto, UpdateCsvBookingRateDto, } 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 = ''; const randomBytes = crypto.randomBytes(6); for (let i = 0; i < 6; i++) { code += chars.charAt(randomBytes[i] % chars.length); } return `XPD-${year}-${code}`; } /** * Derive the document-access password for a booking. * * The password is an HMAC of the booking ID keyed with a server-side secret, * so it can be re-computed when (re)sending carrier emails but cannot be * guessed from any data visible to third parties (unlike the previous * scheme, which reused the last 6 characters of the booking number). */ private deriveDocumentPassword(bookingId: string): string { const secret = process.env.DOCUMENT_PASSWORD_SECRET || process.env.JWT_SECRET || ''; const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity const digest = crypto.createHmac('sha256', secret).update(`doc-password:${bookingId}`).digest(); let password = ''; for (let i = 0; i < 8; i++) { password += chars.charAt(digest[i] % chars.length); } return password; } /** * Re-sync the stored password hash with the derived password and return the * plaintext for inclusion in the carrier email. Self-heals bookings created * before the HMAC-based scheme. */ private async syncDocumentPassword(bookingId: string): Promise { const password = this.deriveDocumentPassword(bookingId); const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { const matches = ormBooking.passwordHash ? await argon2.verify(ormBooking.passwordHash, password).catch(() => false) : false; if (!matches) { ormBooking.passwordHash = await argon2.hash(password); await this.csvBookingRepository['repository'].save(ormBooking); } } return password; } /** * 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.deriveDocumentPassword(bookingId); // Hash the password for storage const passwordHash = await argon2.hash(documentPassword); // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); // Flat per-booking service fee (forfait par booking) based on the org's plan. // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the // booking skips the payment gate and the carrier is notified immediately. const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); const requiresPayment = bookingFeeEur > 0; const initialStatus = requiresPayment ? CsvBookingStatus.PENDING_PAYMENT : CsvBookingStatus.PENDING; // Create domain entity (no email sent yet when a payment is required) let parsedOptions: Record = {}; if (dto.options) { try { const parsed = JSON.parse(dto.options); if (parsed && typeof parsed === 'object') parsedOptions = parsed; } catch { this.logger.warn(`Ignoring invalid options JSON for booking creation`); } } 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, initialStatus, documents, confirmationToken, new Date(), undefined, dto.notes, undefined, bookingNumber, undefined, // commissionRate — flat fee, no percentage requiresPayment ? bookingFeeEur : 0, undefined, // stripePaymentIntentId — set after payment dto.freightTotal, dto.freightCurrency, dto.fobTotal, dto.fobCurrency, parsedOptions ); // 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: ${initialStatus}, booking fee: ${bookingFeeEur > 0 ? `${bookingFeeEur}€` : 'none (custom)'}` ); if (requiresPayment) { // Carrier email + notification are sent after the booking fee is paid } else { // No fee to collect — notify the carrier immediately await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword); await this.notifyBookingRequestSent(booking); } return this.toResponseDto(savedBooking); } /** * Resolve the flat per-booking fee (forfait par booking) for an organization * from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the * plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error — such * bookings are not auto-charged. */ private async resolveBookingFeeEur(organizationId: string): Promise { try { const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const fee = subscription.plan.bookingFeeEur; return fee > 0 ? fee : 0; } catch (error: any) { this.logger.error(`Failed to resolve booking fee: ${error?.message}`); return 0; } } /** * 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('Booking fee 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: `Frais de 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 booking-fee 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 = await this.syncDocumentPassword(booking.id); // NOW send email to carrier + notify the user (booking fee has been paid) await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword); await this.notifyBookingRequestSent(booking); return this.toResponseDto(updatedBooking); } /** * Send the booking request email to the carrier. * Used both when no booking fee is due (at creation) and after the fee is paid. */ private async sendCarrierBookingRequest( booking: CsvBooking, bookingNumber: string | null | undefined, documentPassword: string | null | undefined ): Promise { 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); } } /** * Notify the user that their booking request has been sent to the carrier. */ private async notifyBookingRequestSent(booking: CsvBooking): Promise { 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.`, 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); } } /** * 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 = await this.syncDocumentPassword(booking.id); 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 = await this.syncDocumentPassword(booking.id); // 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'); } // Build proxy download URLs. The file is streamed through the API rather than // served via a presigned storage URL, whose internal host is unreachable from // the carrier's browser. The frontend prepends the public API base URL. const documentsWithUrls = booking.documents.map(doc => ({ id: doc.id, type: doc.type, fileName: doc.fileName, mimeType: doc.mimeType, size: doc.size, downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`, })); 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, }; } /** * Extract the storage object key from a stored document file path. * Handles raw keys, "bucket/key" paths and full URLs. */ private extractDocumentKey(filePath: string): string { 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/', ''); } } return key; } /** * Stream a booking document to the carrier through the API (public, token-based). * * Downloads the file from storage and returns it as a buffer so the API can * proxy it to the carrier's browser. This avoids handing out presigned storage * URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside. * * Applies the same access rules as {@link getDocumentsForCarrier}: the booking * must be ACCEPTED and, when protected, the correct password must be supplied. */ async streamDocumentForCarrier( token: string, documentId: string, password?: string ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> { const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { confirmationToken: token }, }); if (!ormBooking) { throw new NotFoundException('Réservation introuvable'); } if (ormBooking.status !== 'ACCEPTED') { throw new BadRequestException("Cette réservation n'a pas encore été acceptée"); } 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'); } } const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { throw new NotFoundException('Réservation introuvable'); } const document = booking.documents.find(doc => doc.id === documentId); if (!document) { throw new NotFoundException('Document introuvable'); } const bucket = 'xpeditis-documents'; const key = this.extractDocumentKey(document.filePath); const buffer = await this.storageAdapter.download({ bucket, key }); return { buffer, fileName: document.fileName, mimeType: document.mimeType || 'application/octet-stream', }; } /** * 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 the flat per-booking service fee (forfait par booking) from the org's plan const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); booking.applyBookingFee(bookingFeeEur); this.logger.log( `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` ); // 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 = await this.syncDocumentPassword(booking.id); // 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); } /** * Update the cargo details of a booking before payment (user action). * * Only the owner can edit, and only while the booking is PENDING_PAYMENT. */ async updateBookingDetails( id: string, userId: string, dto: UpdateCsvBookingDetailsDto ): Promise { this.logger.log(`Updating booking details ${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'); } // Detect whether any pricing field was provided const hasPricing = dto.priceUSD !== undefined || dto.priceEUR !== undefined || dto.primaryCurrency !== undefined || dto.freightTotal !== undefined || dto.freightCurrency !== undefined || dto.fobTotal !== undefined || dto.fobCurrency !== undefined; // Apply the changes (domain logic validates status + values) try { booking.editDetails({ volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, palletCount: dto.palletCount, notes: dto.notes, pricing: hasPricing ? { priceUSD: dto.priceUSD, priceEUR: dto.priceEUR, primaryCurrency: dto.primaryCurrency, freightTotal: dto.freightTotal, freightCurrency: dto.freightCurrency, fobTotal: dto.fobTotal, fobCurrency: dto.fobCurrency, } : undefined, }); } catch (err) { throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details'); } const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${id} details updated`); return this.toResponseDto(updatedBooking); } /** * Re-apply a full rate selection to a booking before payment (user action). * * Used when the user re-runs the search and picks a (possibly different) rate: * carrier, route, container, transit, cargo and price are all replaced. Only * the owner can edit, and only while the booking is PENDING_PAYMENT. */ async updateBookingRate( id: string, userId: string, dto: UpdateCsvBookingRateDto ): Promise { this.logger.log(`Updating booking rate ${id} by user ${userId}`); const booking = await this.csvBookingRepository.findById(id); if (!booking) { throw new NotFoundException('Booking not found'); } if (booking.userId !== userId) { throw new NotFoundException('Booking not found'); } try { booking.editFromRate({ carrierName: dto.carrierName, carrierEmail: dto.carrierEmail, origin: PortCode.create(dto.origin), destination: PortCode.create(dto.destination), containerType: dto.containerType, transitDays: dto.transitDays, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, palletCount: dto.palletCount, priceUSD: dto.priceUSD, priceEUR: dto.priceEUR, primaryCurrency: dto.primaryCurrency, freightTotal: dto.freightTotal, freightCurrency: dto.freightCurrency, fobTotal: dto.fobTotal, fobCurrency: dto.fobCurrency, notes: dto.notes, options: dto.options, }); } catch (err) { throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate'); } const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${id} rate updated`); 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(); // Multer decodes originalname as latin1; re-encode to utf8 to preserve accented characters const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8'); const fileKey = `csv-bookings/${bookingId}/${documentId}-${fileName}`; // 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(fileName); const document = new CsvBookingDocumentImpl( documentId, documentType, fileName, 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_BANK_TRANSFER, PENDING, or ACCEPTED bookings if ( booking.status !== CsvBookingStatus.PENDING_PAYMENT && booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER && 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`); } // Find the document (no status restriction — aligned with replaceDocument; // deleting down to zero documents is allowed) const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document with ID ${documentId} not found`); } // 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, freightTotal: booking.freightTotal, freightCurrency: booking.freightCurrency, fobTotal: booking.fobTotal, fobCurrency: booking.fobCurrency, options: booking.options ?? {}, }; } /** * 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, }; } }