Le statut PENDING_PAYMENT ne designe plus un paiement en attente mais un devis : une reservation construite dont les frais de booking ne sont pas encore regles. L'enum backend devient QUOTE, converti en place par migration. Les deux onglets se partagent desormais la meme liste : - « Reservations » n'affiche que PENDING, ACCEPTED et REJECTED ; - « Historique des devis » regroupe QUOTE, PENDING_BANK_TRANSFER et CANCELLED, avec les actions propres a un devis (modifier, payer, supprimer). La table, l'export et les filtres sont extraits dans BookingListView pour eviter de dupliquer la page entre les deux vues. Tableau de bord : - l'alerte « Paiement en attente » disparait des points a traiter, les devis non regles ne bloquant aucun envoi en cours ; - « Sans reponse du transporteur » devient « En attente du transporteur ». Migration verifiee en transaction annulee sur la base de dev : les lignes PENDING_PAYMENT deviennent QUOTE, l'enum et le defaut de colonne suivent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm
1641 lines
56 KiB
TypeScript
1641 lines
56 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,
|
|
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<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.QUOTE : CsvBookingStatus.PENDING;
|
|
|
|
// Create domain entity (no email sent yet when a payment is required)
|
|
let parsedOptions: Record<string, boolean> = {};
|
|
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<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.QUOTE) {
|
|
throw new BadRequestException(
|
|
`Booking is not a quote 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.QUOTE) {
|
|
// Already confirmed - return current state
|
|
if (booking.status === CsvBookingStatus.PENDING) {
|
|
return this.toResponseDto(booking);
|
|
}
|
|
throw new BadRequestException(
|
|
`Booking is not a quote 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 QUOTE → 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.QUOTE) {
|
|
throw new BadRequestException(
|
|
`Booking is not a quote 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);
|
|
}
|
|
|
|
/**
|
|
* Delete an unpaid booking (user action).
|
|
*
|
|
* Seul le proprietaire peut supprimer, et seulement tant qu'aucun paiement
|
|
* n'a ete encaisse — voir `CsvBooking.isDeletable()`. Une reservation payee
|
|
* est partie chez le transporteur : elle s'annule, elle ne s'efface pas.
|
|
*
|
|
* Les documents deja televerses restent dans le stockage objet, comme lors de
|
|
* la suppression d'un document isole : la politique du projet est de les
|
|
* conserver pour l'audit.
|
|
*/
|
|
async deleteBooking(id: string, userId: string): Promise<{ success: boolean; message: string }> {
|
|
this.logger.log(`Deleting booking ${id} by user ${userId}`);
|
|
|
|
const booking = await this.csvBookingRepository.findById(id);
|
|
|
|
if (!booking) {
|
|
throw new NotFoundException('Booking not found');
|
|
}
|
|
|
|
// Meme reponse qu'une reservation inexistante : appartenir a quelqu'un
|
|
// d'autre ne doit pas etre distinguable de ne pas exister.
|
|
if (booking.userId !== userId) {
|
|
throw new NotFoundException('Booking not found');
|
|
}
|
|
|
|
if (!booking.isDeletable()) {
|
|
throw new BadRequestException(
|
|
`Cannot delete a booking with status ${booking.status}. Only unpaid bookings can be deleted.`
|
|
);
|
|
}
|
|
|
|
await this.csvBookingRepository.delete(id);
|
|
this.logger.log(`Booking ${id} deleted`);
|
|
|
|
return { success: true, message: 'Booking deleted successfully' };
|
|
}
|
|
|
|
/**
|
|
* Update the cargo details of a booking before payment (user action).
|
|
*
|
|
* Only the owner can edit, and only while the booking is QUOTE.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 QUOTE.
|
|
*/
|
|
async updateBookingRate(
|
|
id: string,
|
|
userId: string,
|
|
dto: UpdateCsvBookingRateDto
|
|
): Promise<CsvBookingResponseDto> {
|
|
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<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 {
|
|
quote: stats[CsvBookingStatus.QUOTE] || 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 {
|
|
quote: stats[CsvBookingStatus.QUOTE] || 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 QUOTE, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings
|
|
if (
|
|
booking.status !== CsvBookingStatus.QUOTE &&
|
|
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,
|
|
};
|
|
}
|
|
}
|