xpeditis2.0/apps/backend/src/application/services/csv-booking.service.ts
2026-02-05 11:53:22 +01:00

966 lines
30 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 { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.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';
/**
* 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
) {}
/**
* 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);
// Create domain entity
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,
documents,
confirmationToken,
new Date(),
undefined,
dto.notes
);
// 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}`);
// Send email to carrier and WAIT for confirmation
// The button waits for the email to be sent before responding
try {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId,
bookingNumber,
documentPassword,
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
transitDays: dto.transitDays,
containerType: dto.containerType,
documents: documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken,
});
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
// Continue even if email fails - booking is already saved
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId,
organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent',
message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`,
metadata: { bookingId, carrierName: dto.carrierName },
});
await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
// Continue even if notification fails
}
return this.toResponseDto(savedBooking);
}
/**
* 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();
// 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 {
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 {
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 or ACCEPTED bookings
if (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
if (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,
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),
};
}
/**
* 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,
};
}
}