xpeditis2.0/apps/backend/src/application/services/csv-booking.service.ts
David bce77edd76 fix(bookings,docs): suppression docs, edition+recalcul prix, menu 3 points, responsive
- Suppression de documents: cause = deleteDocument restreint aux statuts
  PENDING/PENDING_PAYMENT + blocage du dernier document. Alignement sur
  replaceDocument (aucune restriction de statut) et autorisation de 0 document
  (validation domaine assouplie ; creation exige toujours >=1 doc cote service).
- Edition avant paiement: recalcul auto du prix. La page /booking/:id/edit
  relance la recherche de tarif (meme transporteur/conteneur/transit) pour le
  nouveau volume/poids et met a jour fret/FOB/total ; nouveaux champs de prix
  dans UpdateCsvBookingDetailsDto + editDetails.
- Liste reservations: actions regroupees dans un menu 3 points (kebab) en
  positionnement fixed (anti-clipping), desktop + mobile.
- Responsive (<=1280px): tables documents & blog admin en overflow-x-auto,
  menus documents en fixed, grilles de la page edition en sm:.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:56:25 +02:00

1537 lines
52 KiB
TypeScript

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,
} 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<string> {
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<CsvBookingResponseDto> {
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)
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
);
// 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<number> {
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<CsvBookingResponseDto> {
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<void> {
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<void> {
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<CsvBookingResponseDto> {
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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
});
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<void> {
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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
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<CarrierDocumentsResponseDto> {
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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
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);
}
/**
* Get bookings for a user (paginated)
*/
async getUserBookings(
userId: string,
page: number = 1,
limit: number = 10
): Promise<CsvBookingListResponseDto> {
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<CsvBookingListResponseDto> {
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<CsvBookingStatsDto> {
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<CsvBookingStatsDto> {
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<CsvBookingDocumentImpl[]> {
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<void> {
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,
};
}
/**
* 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,
};
}
}