diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index f957d02..576c984 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; +import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; /** * CSV Booking Actions Controller (Public Routes) @@ -88,4 +89,28 @@ export class CsvBookingActionsController { reason: reason || null, }; } + + /** + * Get booking documents for carrier (PUBLIC - token-based) + * + * GET /api/v1/csv-booking-actions/documents/:token + */ + @Public() + @Get('documents/:token') + @ApiOperation({ + summary: 'Get booking documents (public)', + description: + 'Public endpoint for carriers to access booking documents after acceptance. Returns booking summary and documents with signed download URLs.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking documents retrieved successfully.', + type: CarrierDocumentsResponseDto, + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ status: 400, description: 'Booking has not been accepted yet' }) + async getBookingDocuments(@Param('token') token: string): Promise { + return this.csvBookingService.getDocumentsForCarrier(token); + } } diff --git a/apps/backend/src/application/dto/carrier-documents.dto.ts b/apps/backend/src/application/dto/carrier-documents.dto.ts new file mode 100644 index 0000000..1dd8d2e --- /dev/null +++ b/apps/backend/src/application/dto/carrier-documents.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * Booking Summary DTO for Carrier Documents Page + */ +export class BookingSummaryDto { + @ApiProperty({ description: 'Booking unique ID' }) + id: string; + + @ApiProperty({ description: 'Carrier/Company name' }) + carrierName: string; + + @ApiProperty({ description: 'Origin port code' }) + origin: string; + + @ApiProperty({ description: 'Destination port code' }) + destination: string; + + @ApiProperty({ description: 'Route description (origin -> destination)' }) + routeDescription: string; + + @ApiProperty({ description: 'Volume in CBM' }) + volumeCBM: number; + + @ApiProperty({ description: 'Weight in KG' }) + weightKG: number; + + @ApiProperty({ description: 'Number of pallets' }) + palletCount: number; + + @ApiProperty({ description: 'Price in the primary currency' }) + price: number; + + @ApiProperty({ description: 'Currency (USD or EUR)' }) + currency: string; + + @ApiProperty({ description: 'Transit time in days' }) + transitDays: number; + + @ApiProperty({ description: 'Container type' }) + containerType: string; + + @ApiProperty({ description: 'When the booking was accepted' }) + acceptedAt: Date; +} + +/** + * Document with signed download URL for carrier access + */ +export class DocumentWithUrlDto { + @ApiProperty({ description: 'Document unique ID' }) + id: string; + + @ApiProperty({ + description: 'Document type', + enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], + }) + type: string; + + @ApiProperty({ description: 'Original file name' }) + fileName: string; + + @ApiProperty({ description: 'File MIME type' }) + mimeType: string; + + @ApiProperty({ description: 'File size in bytes' }) + size: number; + + @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) + downloadUrl: string; +} + +/** + * Carrier Documents Response DTO + * + * Response for carrier document access page + */ +export class CarrierDocumentsResponseDto { + @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) + booking: BookingSummaryDto; + + @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) + documents: DocumentWithUrlDto[]; +} diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index a7fc9a4..ab685dc 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -21,6 +21,7 @@ import { CsvBookingListResponseDto, CsvBookingStatsDto, } from '../dto/csv-booking.dto'; +import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; /** * CSV Booking Document (simple class for domain) @@ -201,6 +202,84 @@ export class CsvBookingService { return this.toResponseDto(booking); } + /** + * Get booking documents for carrier (public endpoint) + * Only accessible for ACCEPTED bookings + */ + async getDocumentsForCarrier(token: string): Promise { + this.logger.log(`Getting documents for carrier with token: ${token}`); + + const booking = await this.csvBookingRepository.findByToken(token); + + if (!booking) { + throw new NotFoundException('Réservation introuvable'); + } + + // Only allow access for ACCEPTED bookings + if (booking.status !== CsvBookingStatus.ACCEPTED) { + throw new BadRequestException('Cette réservation n\'a pas encore été acceptée'); + } + + // 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, + 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, + }; + } + + /** + * Generate signed URL for a document file path + */ + private async generateSignedUrlForDocument(filePath: string): Promise { + 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 */ @@ -220,6 +299,23 @@ export class CsvBookingService { const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); + // Send document access email to carrier + try { + await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, { + carrierName: booking.carrierName, + bookingId: booking.id, + 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({ @@ -475,9 +571,9 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Verify booking is still pending - if (booking.status !== CsvBookingStatus.PENDING) { - throw new BadRequestException('Cannot add documents to a booking that is not pending'); + // 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 @@ -506,6 +602,24 @@ export class CsvBookingService { 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', diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index a36a580..5fa3ed1 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -120,4 +120,37 @@ export interface EmailPort { carrierName: string, temporaryPassword: string ): Promise; + + /** + * Send document access email to carrier after booking acceptance + */ + sendDocumentAccessEmail( + carrierEmail: string, + data: { + carrierName: string; + bookingId: string; + origin: string; + destination: string; + volumeCBM: number; + weightKG: number; + documentCount: number; + confirmationToken: string; + } + ): Promise; + + /** + * Send notification to carrier when new documents are added + */ + sendNewDocumentsNotification( + carrierEmail: string, + data: { + carrierName: string; + bookingId: string; + origin: string; + destination: string; + newDocumentsCount: number; + totalDocumentsCount: number; + confirmationToken: string; + } + ): Promise; } diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 261a23f..7277ba3 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -427,4 +427,175 @@ export class EmailAdapter implements EmailPort { this.logger.log(`Carrier password reset email sent to ${email}`); } + + /** + * Send document access email to carrier after booking acceptance + */ + async sendDocumentAccessEmail( + carrierEmail: string, + data: { + carrierName: string; + bookingId: string; + origin: string; + destination: string; + volumeCBM: number; + weightKG: number; + documentCount: number; + confirmationToken: string; + } + ): Promise { + const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); + const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; + + const html = ` + + + + + + + + +
+
+

Documents disponibles

+

Votre reservation a ete acceptee

+
+
+

Bonjour ${data.carrierName},

+

Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.

+ +
+ ${data.origin} ${data.destination} +
+ +
+
+ Volume + ${data.volumeCBM} CBM +
+
+ Poids + ${data.weightKG} kg +
+
+ +
+ ${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''} +
+ + Acceder aux documents + +

Ce lien est permanent. Vous pouvez y acceder a tout moment.

+
+ +
+ + + `; + + await this.send({ + to: carrierEmail, + subject: `Documents disponibles - Reservation ${data.origin} → ${data.destination}`, + html, + }); + + this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`); + } + + /** + * Send notification to carrier when new documents are added + */ + async sendNewDocumentsNotification( + carrierEmail: string, + data: { + carrierName: string; + bookingId: string; + origin: string; + destination: string; + newDocumentsCount: number; + totalDocumentsCount: number; + confirmationToken: string; + } + ): Promise { + const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); + const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; + + const html = ` + + + + + + + + +
+
+

Nouveaux documents ajoutes

+
+
+

Bonjour ${data.carrierName},

+

De nouveaux documents ont ete ajoutes a votre reservation.

+ +
+ ${data.origin} ${data.destination} +
+ +
+

+ +${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''} +

+

+ Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''} +

+
+ + Voir les documents +
+ +
+ + + `; + + await this.send({ + to: carrierEmail, + subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`, + html, + }); + + this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`); + } } diff --git a/apps/frontend/app/carrier/documents/[token]/page.tsx b/apps/frontend/app/carrier/documents/[token]/page.tsx new file mode 100644 index 0000000..d81c434 --- /dev/null +++ b/apps/frontend/app/carrier/documents/[token]/page.tsx @@ -0,0 +1,325 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useParams } from 'next/navigation'; +import { + FileText, + Download, + Loader2, + XCircle, + Package, + Ship, + Clock, + AlertCircle, + ArrowRight, +} from 'lucide-react'; + +interface Document { + id: string; + type: string; + fileName: string; + mimeType: string; + size: number; + downloadUrl: string; +} + +interface BookingSummary { + id: string; + carrierName: string; + origin: string; + destination: string; + routeDescription: string; + volumeCBM: number; + weightKG: number; + palletCount: number; + price: number; + currency: string; + transitDays: number; + containerType: string; + acceptedAt: string; +} + +interface CarrierDocumentsData { + booking: BookingSummary; + documents: Document[]; +} + +const documentTypeLabels: Record = { + BILL_OF_LADING: 'Connaissement', + PACKING_LIST: 'Liste de colisage', + COMMERCIAL_INVOICE: 'Facture commerciale', + CERTIFICATE_OF_ORIGIN: "Certificat d'origine", + OTHER: 'Autre document', +}; + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const getFileIcon = (mimeType: string) => { + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('image')) return '🖼️'; + if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊'; + if (mimeType.includes('word') || mimeType.includes('document')) return '📝'; + return '📎'; +}; + +export default function CarrierDocumentsPage() { + const params = useParams(); + const token = params.token as string; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [downloading, setDownloading] = useState(null); + + const hasCalledApi = useRef(false); + + const fetchDocuments = async () => { + if (!token) { + setError('Lien invalide'); + setLoading(false); + return; + } + + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = { message: `Erreur HTTP ${response.status}` }; + } + + const errorMessage = errorData.message || 'Erreur lors du chargement des documents'; + + if (errorMessage.includes('pas encore été acceptée') || errorMessage.includes('not accepted')) { + throw new Error('Cette réservation n\'a pas encore été acceptée. Les documents seront disponibles après l\'acceptation.'); + } else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { + throw new Error('Réservation introuvable. Vérifiez que le lien est correct.'); + } + + throw new Error(errorMessage); + } + + const responseData = await response.json(); + setData(responseData); + setLoading(false); + } catch (err) { + console.error('Error fetching documents:', err); + setError(err instanceof Error ? err.message : 'Erreur lors du chargement'); + setLoading(false); + } + }; + + useEffect(() => { + if (hasCalledApi.current) return; + hasCalledApi.current = true; + fetchDocuments(); + }, [token]); + + const handleDownload = async (doc: Document) => { + setDownloading(doc.id); + + try { + // The downloadUrl is already a signed URL, open it directly + window.open(doc.downloadUrl, '_blank'); + } catch (err) { + console.error('Error downloading document:', err); + alert('Erreur lors du téléchargement. Veuillez réessayer.'); + } finally { + // Small delay to show loading state + setTimeout(() => setDownloading(null), 500); + } + }; + + const handleRefresh = () => { + setLoading(true); + setError(null); + hasCalledApi.current = false; + fetchDocuments(); + }; + + if (loading) { + return ( +
+
+ +

+ Chargement des documents... +

+

Veuillez patienter

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

Erreur

+

{error}

+ +
+
+ ); + } + + if (!data) return null; + + const { booking, documents } = data; + + return ( +
+ {/* Header */} +
+
+
+ + Xpeditis +
+ +
+
+ +
+ {/* Booking Summary Card */} +
+
+
+ {booking.origin} + + {booking.destination} +
+
+ +
+
+
+ +

Volume

+

{booking.volumeCBM} CBM

+
+
+ +

Poids

+

{booking.weightKG} kg

+
+
+ +

Transit

+

{booking.transitDays} jours

+
+
+ +

Type

+

{booking.containerType}

+
+
+ +
+ + Transporteur: {booking.carrierName} + + + Ref: {booking.id.substring(0, 8).toUpperCase()} + +
+
+
+ + {/* Documents Section */} +
+
+

+ + Documents ({documents.length}) +

+
+ + {documents.length === 0 ? ( +
+ +

Aucun document disponible pour le moment.

+

Les documents apparaîtront ici une fois ajoutés.

+
+ ) : ( +
+ {documents.map((doc) => ( +
+
+ {getFileIcon(doc.mimeType)} +
+

{doc.fileName}

+
+ + {documentTypeLabels[doc.type] || doc.type} + + + {formatFileSize(doc.size)} + +
+
+
+ + +
+ ))} +
+ )} +
+ + {/* Info */} +

+ Cette page affiche toujours les documents les plus récents de la réservation. +

+
+ + {/* Footer */} + +
+ ); +}