533 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|