All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
1370 lines
46 KiB
TypeScript
1370 lines
46 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
Inject,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import * as argon2 from 'argon2';
|
|
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
|
import { PortCode } from '@domain/value-objects/port-code.vo';
|
|
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
|
import {
|
|
NotificationRepository,
|
|
NOTIFICATION_REPOSITORY,
|
|
} from '@domain/ports/out/notification.repository';
|
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
|
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
|
import {
|
|
Notification,
|
|
NotificationType,
|
|
NotificationPriority,
|
|
} from '@domain/entities/notification.entity';
|
|
import {
|
|
CreateCsvBookingDto,
|
|
CsvBookingResponseDto,
|
|
CsvBookingDocumentDto,
|
|
CsvBookingListResponseDto,
|
|
CsvBookingStatsDto,
|
|
} from '../dto/csv-booking.dto';
|
|
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
|
import { SubscriptionService } from './subscription.service';
|
|
|
|
/**
|
|
* CSV Booking Document (simple class for domain)
|
|
*/
|
|
class CsvBookingDocumentImpl {
|
|
constructor(
|
|
public readonly id: string,
|
|
public readonly type: DocumentType,
|
|
public readonly fileName: string,
|
|
public readonly filePath: string,
|
|
public readonly mimeType: string,
|
|
public readonly size: number,
|
|
public readonly uploadedAt: Date
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* CSV Booking Service
|
|
*
|
|
* Handles business logic for CSV-based booking requests
|
|
*/
|
|
@Injectable()
|
|
export class CsvBookingService {
|
|
private readonly logger = new Logger(CsvBookingService.name);
|
|
|
|
constructor(
|
|
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
|
|
@Inject(NOTIFICATION_REPOSITORY)
|
|
private readonly notificationRepository: NotificationRepository,
|
|
@Inject(EMAIL_PORT)
|
|
private readonly emailAdapter: EmailPort,
|
|
@Inject(STORAGE_PORT)
|
|
private readonly storageAdapter: StoragePort,
|
|
@Inject(STRIPE_PORT)
|
|
private readonly stripeAdapter: StripePort,
|
|
private readonly subscriptionService: SubscriptionService,
|
|
@Inject(USER_REPOSITORY)
|
|
private readonly userRepository: UserRepository
|
|
) {}
|
|
|
|
/**
|
|
* Generate a unique booking number
|
|
* Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9)
|
|
*/
|
|
private generateBookingNumber(): string {
|
|
const year = new Date().getFullYear();
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
|
let code = '';
|
|
for (let i = 0; i < 6; i++) {
|
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return `XPD-${year}-${code}`;
|
|
}
|
|
|
|
/**
|
|
* Extract the password from booking number (last 6 characters)
|
|
*/
|
|
private extractPasswordFromBookingNumber(bookingNumber: string): string {
|
|
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
|
|
}
|
|
|
|
/**
|
|
* Create a new CSV booking request
|
|
*/
|
|
async createBooking(
|
|
dto: CreateCsvBookingDto,
|
|
files: Express.Multer.File[],
|
|
userId: string,
|
|
organizationId: string
|
|
): Promise<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.extractPasswordFromBookingNumber(bookingNumber);
|
|
|
|
// Hash the password for storage
|
|
const passwordHash = await argon2.hash(documentPassword);
|
|
|
|
// Upload documents to S3
|
|
const documents = await this.uploadDocuments(files, bookingId);
|
|
|
|
// Calculate commission based on organization's subscription plan
|
|
let commissionRate = 5; // default Bronze
|
|
let commissionAmountEur = 0;
|
|
try {
|
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
|
commissionRate = subscription.plan.commissionRatePercent;
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
|
|
}
|
|
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
|
|
|
|
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
|
|
const booking = new CsvBooking(
|
|
bookingId,
|
|
userId,
|
|
organizationId,
|
|
dto.carrierName,
|
|
dto.carrierEmail,
|
|
PortCode.create(dto.origin),
|
|
PortCode.create(dto.destination),
|
|
dto.volumeCBM,
|
|
dto.weightKG,
|
|
dto.palletCount,
|
|
dto.priceUSD,
|
|
dto.priceEUR,
|
|
dto.primaryCurrency,
|
|
dto.transitDays,
|
|
dto.containerType,
|
|
CsvBookingStatus.PENDING_PAYMENT,
|
|
documents,
|
|
confirmationToken,
|
|
new Date(),
|
|
undefined,
|
|
dto.notes,
|
|
undefined,
|
|
bookingNumber,
|
|
commissionRate,
|
|
commissionAmountEur
|
|
);
|
|
|
|
// Save to database
|
|
const savedBooking = await this.csvBookingRepository.create(booking);
|
|
|
|
// Update ORM entity with booking number and password hash
|
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
where: { id: bookingId },
|
|
});
|
|
if (ormBooking) {
|
|
ormBooking.bookingNumber = bookingNumber;
|
|
ormBooking.passwordHash = passwordHash;
|
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
}
|
|
|
|
this.logger.log(
|
|
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€`
|
|
);
|
|
|
|
// NO email sent to carrier yet - will be sent after commission payment
|
|
// NO notification yet - will be created after payment confirmation
|
|
|
|
return this.toResponseDto(savedBooking);
|
|
}
|
|
|
|
/**
|
|
* Create a Stripe Checkout session for commission payment
|
|
*/
|
|
async createCommissionPayment(
|
|
bookingId: string,
|
|
userId: string,
|
|
userEmail: string,
|
|
frontendUrl: string
|
|
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
|
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
|
|
if (!booking) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
if (booking.userId !== userId) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
throw new BadRequestException(
|
|
`Booking is not awaiting payment. Current status: ${booking.status}`
|
|
);
|
|
}
|
|
|
|
const commissionAmountEur = booking.commissionAmountEur || 0;
|
|
if (commissionAmountEur <= 0) {
|
|
throw new BadRequestException('Commission amount is invalid');
|
|
}
|
|
|
|
const amountCents = Math.round(commissionAmountEur * 100);
|
|
|
|
const result = await this.stripeAdapter.createCommissionCheckout({
|
|
bookingId: booking.id,
|
|
amountCents,
|
|
currency: 'eur',
|
|
customerEmail: userEmail,
|
|
organizationId: booking.organizationId,
|
|
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`,
|
|
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
|
|
});
|
|
|
|
this.logger.log(
|
|
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
|
|
);
|
|
|
|
return {
|
|
sessionUrl: result.sessionUrl,
|
|
sessionId: result.sessionId,
|
|
commissionAmountEur,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Confirm commission payment and activate booking
|
|
* Called after Stripe redirect with session_id
|
|
*/
|
|
async confirmCommissionPayment(
|
|
bookingId: string,
|
|
sessionId: string,
|
|
userId: string
|
|
): Promise<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 = bookingNumber
|
|
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
: undefined;
|
|
|
|
// NOW send email to carrier
|
|
try {
|
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
|
bookingId: booking.id,
|
|
bookingNumber: bookingNumber || '',
|
|
documentPassword: documentPassword || '',
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
palletCount: booking.palletCount,
|
|
priceUSD: booking.priceUSD,
|
|
priceEUR: booking.priceEUR,
|
|
primaryCurrency: booking.primaryCurrency,
|
|
transitDays: booking.transitDays,
|
|
containerType: booking.containerType,
|
|
documents: booking.documents.map(doc => ({
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
})),
|
|
confirmationToken: booking.confirmationToken,
|
|
notes: booking.notes,
|
|
});
|
|
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
// Create notification for user
|
|
try {
|
|
const notification = Notification.create({
|
|
id: uuidv4(),
|
|
userId: booking.userId,
|
|
organizationId: booking.organizationId,
|
|
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
|
|
priority: NotificationPriority.MEDIUM,
|
|
title: 'Booking Request Sent',
|
|
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
|
|
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
|
|
});
|
|
await this.notificationRepository.save(notification);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
return this.toResponseDto(updatedBooking);
|
|
}
|
|
|
|
/**
|
|
* Declare bank transfer — user confirms they have sent the wire transfer
|
|
* Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER
|
|
* Sends an email notification to all ADMIN users
|
|
*/
|
|
async declareBankTransfer(bookingId: string, userId: string): Promise<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 = bookingNumber
|
|
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
: undefined;
|
|
|
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
|
bookingId: booking.id,
|
|
bookingNumber: bookingNumber || '',
|
|
documentPassword: documentPassword || '',
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
palletCount: booking.palletCount,
|
|
priceUSD: booking.priceUSD,
|
|
priceEUR: booking.priceEUR,
|
|
primaryCurrency: booking.primaryCurrency,
|
|
transitDays: booking.transitDays,
|
|
containerType: booking.containerType,
|
|
documents: booking.documents.map(doc => ({
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
})),
|
|
confirmationToken: booking.confirmationToken,
|
|
notes: booking.notes,
|
|
});
|
|
|
|
this.logger.log(
|
|
`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Admin validates bank transfer — confirms receipt and activates booking
|
|
* Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier
|
|
*/
|
|
async validateBankTransfer(bookingId: string): Promise<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 = bookingNumber
|
|
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
: undefined;
|
|
|
|
// Send email to carrier
|
|
try {
|
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
|
bookingId: booking.id,
|
|
bookingNumber: bookingNumber || '',
|
|
documentPassword: documentPassword || '',
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
palletCount: booking.palletCount,
|
|
priceUSD: booking.priceUSD,
|
|
priceEUR: booking.priceEUR,
|
|
primaryCurrency: booking.primaryCurrency,
|
|
transitDays: booking.transitDays,
|
|
containerType: booking.containerType,
|
|
documents: booking.documents.map(doc => ({
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
})),
|
|
confirmationToken: booking.confirmationToken,
|
|
notes: booking.notes,
|
|
});
|
|
this.logger.log(
|
|
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
|
|
);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
// In-app notification for the user
|
|
try {
|
|
const notification = Notification.create({
|
|
id: uuidv4(),
|
|
userId: booking.userId,
|
|
organizationId: booking.organizationId,
|
|
type: NotificationType.BOOKING_CONFIRMED,
|
|
priority: NotificationPriority.HIGH,
|
|
title: 'Virement validé — Booking activé',
|
|
message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`,
|
|
metadata: { bookingId: booking.id },
|
|
});
|
|
await this.notificationRepository.save(notification);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
return this.toResponseDto(updatedBooking);
|
|
}
|
|
|
|
/**
|
|
* Get booking by ID
|
|
* Accessible by: booking owner OR assigned carrier
|
|
*/
|
|
async getBookingById(
|
|
id: string,
|
|
userId: string,
|
|
carrierId?: string
|
|
): Promise<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');
|
|
}
|
|
|
|
// Generate signed URLs for all documents
|
|
const documentsWithUrls = await Promise.all(
|
|
booking.documents.map(async doc => {
|
|
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
|
return {
|
|
id: doc.id,
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
mimeType: doc.mimeType,
|
|
size: doc.size,
|
|
downloadUrl: signedUrl,
|
|
};
|
|
})
|
|
);
|
|
|
|
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
|
|
|
return {
|
|
booking: {
|
|
id: booking.id,
|
|
bookingNumber: ormBooking.bookingNumber || undefined,
|
|
carrierName: booking.carrierName,
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
routeDescription: booking.getRouteDescription(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
palletCount: booking.palletCount,
|
|
price: booking.getPriceInCurrency(primaryCurrency),
|
|
currency: primaryCurrency,
|
|
transitDays: booking.transitDays,
|
|
containerType: booking.containerType,
|
|
acceptedAt: booking.respondedAt!,
|
|
},
|
|
documents: documentsWithUrls,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a booking requires password for document access
|
|
*/
|
|
async checkDocumentAccessRequirements(
|
|
token: string
|
|
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
|
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
where: { confirmationToken: token },
|
|
});
|
|
|
|
if (!ormBooking) {
|
|
throw new NotFoundException('Réservation introuvable');
|
|
}
|
|
|
|
return {
|
|
requiresPassword: !!ormBooking.passwordHash,
|
|
bookingNumber: ormBooking.bookingNumber || undefined,
|
|
status: ormBooking.status,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate signed URL for a document file path
|
|
*/
|
|
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
|
const bucket = 'xpeditis-documents';
|
|
|
|
// Extract key from the file path
|
|
let key = filePath;
|
|
if (filePath.includes('xpeditis-documents/')) {
|
|
key = filePath.split('xpeditis-documents/')[1];
|
|
} else if (filePath.startsWith('http')) {
|
|
const url = new URL(filePath);
|
|
key = url.pathname.replace(/^\//, '');
|
|
if (key.startsWith('xpeditis-documents/')) {
|
|
key = key.replace('xpeditis-documents/', '');
|
|
}
|
|
}
|
|
|
|
// Generate signed URL with 1 hour expiration
|
|
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
|
return signedUrl;
|
|
}
|
|
|
|
/**
|
|
* Accept a booking request
|
|
*/
|
|
async acceptBooking(token: string): Promise<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 commission based on organization's subscription plan
|
|
try {
|
|
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
booking.organizationId
|
|
);
|
|
const commissionRate = subscription.plan.commissionRatePercent;
|
|
const baseAmountEur = booking.priceEUR;
|
|
booking.applyCommission(commissionRate, baseAmountEur);
|
|
this.logger.log(
|
|
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€`
|
|
);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
// Save updated booking
|
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
|
this.logger.log(`Booking ${booking.id} accepted`);
|
|
|
|
// Extract password from booking number for the email
|
|
const bookingNumber = ormBooking?.bookingNumber;
|
|
const documentPassword = bookingNumber
|
|
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
: undefined;
|
|
|
|
// Send document access email to carrier
|
|
try {
|
|
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
|
carrierName: booking.carrierName,
|
|
bookingId: booking.id,
|
|
bookingNumber: bookingNumber || undefined,
|
|
documentPassword: documentPassword,
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
documentCount: booking.documents.length,
|
|
confirmationToken: booking.confirmationToken,
|
|
});
|
|
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
// Create notification for user
|
|
try {
|
|
const notification = Notification.create({
|
|
id: uuidv4(),
|
|
userId: booking.userId,
|
|
organizationId: booking.organizationId,
|
|
type: NotificationType.CSV_BOOKING_ACCEPTED,
|
|
priority: NotificationPriority.HIGH,
|
|
title: 'Booking Request Accepted',
|
|
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been accepted!`,
|
|
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
|
|
});
|
|
await this.notificationRepository.save(notification);
|
|
} catch (error: any) {
|
|
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
|
}
|
|
|
|
return this.toResponseDto(updatedBooking);
|
|
}
|
|
|
|
/**
|
|
* Reject a booking request
|
|
*/
|
|
async rejectBooking(token: string, reason?: string): Promise<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);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`;
|
|
|
|
// Upload to S3
|
|
const uploadResult = await this.storageAdapter.upload({
|
|
bucket,
|
|
key: fileKey,
|
|
body: file.buffer,
|
|
contentType: file.mimetype,
|
|
});
|
|
|
|
// Determine document type from filename or default to OTHER
|
|
const documentType = this.inferDocumentType(file.originalname);
|
|
|
|
const document = new CsvBookingDocumentImpl(
|
|
documentId,
|
|
documentType,
|
|
file.originalname,
|
|
uploadResult.url,
|
|
file.mimetype,
|
|
file.size,
|
|
new Date()
|
|
);
|
|
|
|
documents.push(document);
|
|
}
|
|
|
|
this.logger.log(`Uploaded ${documents.length} documents for booking ${bookingId}`);
|
|
return documents;
|
|
}
|
|
|
|
/**
|
|
* Link a booking to a carrier profile
|
|
*/
|
|
async linkBookingToCarrier(bookingId: string, carrierId: string): Promise<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, or ACCEPTED bookings
|
|
if (
|
|
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
|
booking.status !== CsvBookingStatus.PENDING &&
|
|
booking.status !== CsvBookingStatus.ACCEPTED
|
|
) {
|
|
throw new BadRequestException(
|
|
'Cannot add documents to a booking that is rejected or cancelled'
|
|
);
|
|
}
|
|
|
|
// Upload new documents
|
|
const newDocuments = await this.uploadDocuments(files, bookingId);
|
|
|
|
// Add documents to booking
|
|
const updatedDocuments = [...booking.documents, ...newDocuments];
|
|
|
|
// Update booking in database using ORM repository directly
|
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
where: { id: bookingId },
|
|
});
|
|
|
|
if (ormBooking) {
|
|
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
id: doc.id,
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
filePath: doc.filePath,
|
|
mimeType: doc.mimeType,
|
|
size: doc.size,
|
|
uploadedAt: doc.uploadedAt,
|
|
}));
|
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
}
|
|
|
|
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
|
|
|
// If booking is ACCEPTED, notify carrier about new documents
|
|
if (booking.status === CsvBookingStatus.ACCEPTED) {
|
|
try {
|
|
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
|
|
carrierName: booking.carrierName,
|
|
bookingId: booking.id,
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
newDocumentsCount: newDocuments.length,
|
|
totalDocumentsCount: updatedDocuments.length,
|
|
confirmationToken: booking.confirmationToken,
|
|
});
|
|
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Failed to send new documents notification: ${error?.message}`,
|
|
error?.stack
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Documents added successfully',
|
|
documentsAdded: newDocuments.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete a document from a booking
|
|
*/
|
|
async deleteDocument(
|
|
bookingId: string,
|
|
documentId: string,
|
|
userId: string
|
|
): Promise<{ success: boolean; message: string }> {
|
|
this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`);
|
|
|
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
|
|
if (!booking) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
// Verify user owns this booking
|
|
if (booking.userId !== userId) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
// Verify booking is still pending or awaiting payment
|
|
if (
|
|
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
|
booking.status !== CsvBookingStatus.PENDING
|
|
) {
|
|
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
|
}
|
|
|
|
// Find the document
|
|
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
|
|
if (documentIndex === -1) {
|
|
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
}
|
|
|
|
// Ensure at least one document remains
|
|
if (booking.documents.length <= 1) {
|
|
throw new BadRequestException(
|
|
'Cannot delete the last document. At least one document is required.'
|
|
);
|
|
}
|
|
|
|
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
|
|
const _documentToDelete = booking.documents[documentIndex];
|
|
|
|
// Remove document from array
|
|
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
|
|
|
// Update booking in database using ORM repository directly
|
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
where: { id: bookingId },
|
|
});
|
|
|
|
if (ormBooking) {
|
|
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
id: doc.id,
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
filePath: doc.filePath,
|
|
mimeType: doc.mimeType,
|
|
size: doc.size,
|
|
uploadedAt: doc.uploadedAt,
|
|
}));
|
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
}
|
|
|
|
// Optionally delete from S3 (commented out for safety - keep files for audit)
|
|
// try {
|
|
// await this.storageAdapter.delete({
|
|
// bucket: 'xpeditis-documents',
|
|
// key: documentToDelete.filePath,
|
|
// });
|
|
// } catch (error) {
|
|
// this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`);
|
|
// }
|
|
|
|
this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`);
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Document deleted successfully',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Replace a document in an existing booking
|
|
*/
|
|
async replaceDocument(
|
|
bookingId: string,
|
|
documentId: string,
|
|
file: Express.Multer.File,
|
|
userId: string
|
|
): Promise<{ success: boolean; message: string; newDocument: any }> {
|
|
this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`);
|
|
|
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
|
|
if (!booking) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
// Verify user owns this booking
|
|
if (booking.userId !== userId) {
|
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
}
|
|
|
|
// Find the document to replace
|
|
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
|
|
if (documentIndex === -1) {
|
|
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
}
|
|
|
|
// Upload the new document
|
|
const newDocuments = await this.uploadDocuments([file], bookingId);
|
|
const newDocument = newDocuments[0];
|
|
|
|
// Replace the document in the array
|
|
const updatedDocuments = [...booking.documents];
|
|
updatedDocuments[documentIndex] = newDocument;
|
|
|
|
// Update booking in database using ORM repository directly
|
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
where: { id: bookingId },
|
|
});
|
|
|
|
if (ormBooking) {
|
|
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
id: doc.id,
|
|
type: doc.type,
|
|
fileName: doc.fileName,
|
|
filePath: doc.filePath,
|
|
mimeType: doc.mimeType,
|
|
size: doc.size,
|
|
uploadedAt: doc.uploadedAt,
|
|
}));
|
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
}
|
|
|
|
this.logger.log(
|
|
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Document replaced successfully',
|
|
newDocument: {
|
|
id: newDocument.id,
|
|
type: newDocument.type,
|
|
fileName: newDocument.fileName,
|
|
filePath: newDocument.filePath,
|
|
mimeType: newDocument.mimeType,
|
|
size: newDocument.size,
|
|
uploadedAt: newDocument.uploadedAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Infer document type from filename
|
|
*/
|
|
private inferDocumentType(filename: string): DocumentType {
|
|
const lowerFilename = filename.toLowerCase();
|
|
|
|
if (
|
|
lowerFilename.includes('bill') ||
|
|
lowerFilename.includes('bol') ||
|
|
lowerFilename.includes('lading')
|
|
) {
|
|
return DocumentType.BILL_OF_LADING;
|
|
}
|
|
if (lowerFilename.includes('packing') || lowerFilename.includes('list')) {
|
|
return DocumentType.PACKING_LIST;
|
|
}
|
|
if (lowerFilename.includes('invoice') || lowerFilename.includes('commercial')) {
|
|
return DocumentType.COMMERCIAL_INVOICE;
|
|
}
|
|
if (lowerFilename.includes('certificate') || lowerFilename.includes('origin')) {
|
|
return DocumentType.CERTIFICATE_OF_ORIGIN;
|
|
}
|
|
|
|
return DocumentType.OTHER;
|
|
}
|
|
|
|
/**
|
|
* Convert domain entity to response DTO
|
|
*/
|
|
private toResponseDto(booking: CsvBooking): CsvBookingResponseDto {
|
|
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
|
|
|
return {
|
|
id: booking.id,
|
|
bookingNumber: booking.bookingNumber,
|
|
userId: booking.userId,
|
|
organizationId: booking.organizationId,
|
|
carrierName: booking.carrierName,
|
|
carrierEmail: booking.carrierEmail,
|
|
origin: booking.origin.getValue(),
|
|
destination: booking.destination.getValue(),
|
|
volumeCBM: booking.volumeCBM,
|
|
weightKG: booking.weightKG,
|
|
palletCount: booking.palletCount,
|
|
priceUSD: booking.priceUSD,
|
|
priceEUR: booking.priceEUR,
|
|
primaryCurrency: booking.primaryCurrency,
|
|
transitDays: booking.transitDays,
|
|
containerType: booking.containerType,
|
|
status: booking.status,
|
|
documents: booking.documents.map(this.toDocumentDto),
|
|
confirmationToken: booking.confirmationToken,
|
|
requestedAt: booking.requestedAt,
|
|
respondedAt: booking.respondedAt || null,
|
|
notes: booking.notes,
|
|
rejectionReason: booking.rejectionReason,
|
|
routeDescription: booking.getRouteDescription(),
|
|
isExpired: booking.isExpired(),
|
|
price: booking.getPriceInCurrency(primaryCurrency),
|
|
commissionRate: booking.commissionRate,
|
|
commissionAmountEur: booking.commissionAmountEur,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert domain document to DTO
|
|
*/
|
|
private toDocumentDto(document: any): CsvBookingDocumentDto {
|
|
return {
|
|
id: document.id,
|
|
type: document.type,
|
|
fileName: document.fileName,
|
|
filePath: document.filePath,
|
|
mimeType: document.mimeType,
|
|
size: document.size,
|
|
uploadedAt: document.uploadedAt,
|
|
};
|
|
}
|
|
}
|