xpeditis2.0/apps/backend/src/application/services/csv-booking.service.ts
2025-12-16 00:26:03 +01:00

533 lines
16 KiB
TypeScript

import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
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';
/**
* 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
) {}
/**
* 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
const confirmationToken = uuidv4();
const bookingId = uuidv4();
// 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);
this.logger.log(`CSV booking created with ID: ${bookingId}`);
// 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,
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);
}
/**
* 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');
}
// 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`);
// 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}`);
}
}
/**
* 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,
};
}
}