From de4126a657c0dba2b5fe6f0e2505b482a283e73a Mon Sep 17 00:00:00 2001 From: David Date: Sat, 17 Jan 2026 15:47:03 +0100 Subject: [PATCH] fix document --- .../controllers/csv-bookings.controller.ts | 296 +++++++++++------- .../services/csv-booking.service.ts | 72 +++++ .../src/domain/entities/csv-booking.entity.ts | 60 ++++ .../typeorm/mappers/csv-booking.mapper.ts | 5 +- .../frontend/app/dashboard/documents/page.tsx | 273 +++++++++++++--- 5 files changed, 557 insertions(+), 149 deletions(-) diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 26e3bed..6ee0ad0 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -40,12 +40,20 @@ import { * CSV Bookings Controller * * Handles HTTP requests for CSV-based booking requests + * + * IMPORTANT: Route order matters in NestJS! + * Static routes MUST come BEFORE parameterized routes. + * Otherwise, `:id` will capture "stats", "organization", etc. */ @ApiTags('CSV Bookings') @Controller('csv-bookings') export class CsvBookingsController { constructor(private readonly csvBookingService: CsvBookingService) {} + // ============================================================================ + // STATIC ROUTES (must come FIRST) + // ============================================================================ + /** * Create a new CSV booking request * @@ -152,6 +160,112 @@ export class CsvBookingsController { return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId); } + /** + * Get current user's bookings (paginated) + * + * GET /api/v1/csv-bookings + */ + @Get() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get user bookings', + description: 'Retrieve all bookings for the authenticated user with pagination.', + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUserBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.getUserBookings(userId, page, limit); + } + + /** + * Get booking statistics for user + * + * GET /api/v1/csv-bookings/stats/me + */ + @Get('stats/me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get user booking statistics', + description: + 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).', + }) + @ApiResponse({ + status: 200, + description: 'Statistics retrieved successfully', + type: CsvBookingStatsDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUserStats(@Request() req: any): Promise { + const userId = req.user.id; + return await this.csvBookingService.getUserStats(userId); + } + + /** + * Get organization booking statistics + * + * GET /api/v1/csv-bookings/stats/organization + */ + @Get('stats/organization') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization booking statistics', + description: "Get aggregated statistics for the user's organization. For managers/admins.", + }) + @ApiResponse({ + status: 200, + description: 'Statistics retrieved successfully', + type: CsvBookingStatsDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationStats(@Request() req: any): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationStats(organizationId); + } + + /** + * Get organization bookings (for managers/admins) + * + * GET /api/v1/csv-bookings/organization/all + */ + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Organization bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); + } + /** * Accept a booking request (PUBLIC - token-based) * @@ -227,10 +341,17 @@ export class CsvBookingsController { }; } + // ============================================================================ + // PARAMETERIZED ROUTES (must come LAST) + // ============================================================================ + /** * Get a booking by ID * * GET /api/v1/csv-bookings/:id + * + * IMPORTANT: This route MUST be after all static GET routes + * Otherwise it will capture "stats", "organization", etc. */ @Get(':id') @UseGuards(JwtAuthGuard) @@ -253,59 +374,6 @@ export class CsvBookingsController { return await this.csvBookingService.getBookingById(id, userId, carrierId); } - /** - * Get current user's bookings (paginated) - * - * GET /api/v1/csv-bookings - */ - @Get() - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get user bookings', - description: 'Retrieve all bookings for the authenticated user with pagination.', - }) - @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) - @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) - @ApiResponse({ - status: 200, - description: 'Bookings retrieved successfully', - type: CsvBookingListResponseDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getUserBookings( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number - ): Promise { - const userId = req.user.id; - return await this.csvBookingService.getUserBookings(userId, page, limit); - } - - /** - * Get booking statistics for user - * - * GET /api/v1/csv-bookings/stats/me - */ - @Get('stats/me') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get user booking statistics', - description: - 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).', - }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - type: CsvBookingStatsDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getUserStats(@Request() req: any): Promise { - const userId = req.user.id; - return await this.csvBookingService.getUserStats(userId); - } - /** * Cancel a booking (user action) * @@ -335,59 +403,6 @@ export class CsvBookingsController { return await this.csvBookingService.cancelBooking(id, userId); } - /** - * Get organization bookings (for managers/admins) - * - * GET /api/v1/csv-bookings/organization/all - */ - @Get('organization/all') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get organization bookings', - description: - "Retrieve all bookings for the user's organization with pagination. For managers/admins.", - }) - @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) - @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) - @ApiResponse({ - status: 200, - description: 'Organization bookings retrieved successfully', - type: CsvBookingListResponseDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getOrganizationBookings( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number - ): Promise { - const organizationId = req.user.organizationId; - return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); - } - - /** - * Get organization booking statistics - * - * GET /api/v1/csv-bookings/stats/organization - */ - @Get('stats/organization') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get organization booking statistics', - description: "Get aggregated statistics for the user's organization. For managers/admins.", - }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - type: CsvBookingStatsDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getOrganizationStats(@Request() req: any): Promise { - const organizationId = req.user.organizationId; - return await this.csvBookingService.getOrganizationStats(organizationId); - } - /** * Add documents to an existing booking * @@ -444,6 +459,75 @@ export class CsvBookingsController { return await this.csvBookingService.addDocuments(id, files, userId); } + /** + * Replace a document in a booking + * + * PUT /api/v1/csv-bookings/:bookingId/documents/:documentId + */ + @Patch(':bookingId/documents/:documentId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('document', 1)) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Replace a document in a booking', + description: + 'Replace an existing document with a new one. Only the booking owner can replace documents.', + }) + @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + document: { + type: 'string', + format: 'binary', + description: 'New document file to replace the existing one', + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Document replaced successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Document replaced successfully' }, + newDocument: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string' }, + fileName: { type: 'string' }, + filePath: { type: 'string' }, + mimeType: { type: 'string' }, + size: { type: 'number' }, + uploadedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid request - missing file' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async replaceDocument( + @Param('bookingId') bookingId: string, + @Param('documentId') documentId: string, + @UploadedFiles() files: Express.Multer.File[], + @Request() req: any + ) { + if (!files || files.length === 0) { + throw new BadRequestException('A document file is required'); + } + + const userId = req.user.id; + return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId); + } + /** * Delete a document from a booking * diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index ee87fbd..a7fc9a4 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -595,6 +595,78 @@ export class CsvBookingService { }; } + /** + * 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 */ diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 7d92ab0..30f3842 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -332,4 +332,64 @@ export class CsvBooking { toString(): string { return this.getSummary(); } + + /** + * Create a CsvBooking from persisted data (skips document validation) + * + * Use this when loading from database where bookings might have been created + * before document requirement was enforced, or documents were lost. + */ + static fromPersistence( + id: string, + userId: string, + organizationId: string, + carrierName: string, + carrierEmail: string, + origin: PortCode, + destination: PortCode, + volumeCBM: number, + weightKG: number, + palletCount: number, + priceUSD: number, + priceEUR: number, + primaryCurrency: string, + transitDays: number, + containerType: string, + status: CsvBookingStatus, + documents: CsvBookingDocument[], + confirmationToken: string, + requestedAt: Date, + respondedAt?: Date, + notes?: string, + rejectionReason?: string + ): CsvBooking { + // Create instance without calling constructor validation + const booking = Object.create(CsvBooking.prototype); + + // Assign all properties directly + booking.id = id; + booking.userId = userId; + booking.organizationId = organizationId; + booking.carrierName = carrierName; + booking.carrierEmail = carrierEmail; + booking.origin = origin; + booking.destination = destination; + booking.volumeCBM = volumeCBM; + booking.weightKG = weightKG; + booking.palletCount = palletCount; + booking.priceUSD = priceUSD; + booking.priceEUR = priceEUR; + booking.primaryCurrency = primaryCurrency; + booking.transitDays = transitDays; + booking.containerType = containerType; + booking.status = status; + booking.documents = documents || []; + booking.confirmationToken = confirmationToken; + booking.requestedAt = requestedAt; + booking.respondedAt = respondedAt; + booking.notes = notes; + booking.rejectionReason = rejectionReason; + + return booking; + } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts index 778685f..66a8912 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts @@ -14,9 +14,12 @@ import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity'; export class CsvBookingMapper { /** * Map ORM entity to domain entity + * + * Uses fromPersistence to avoid validation errors when loading legacy data + * that might have empty documents array */ static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking { - return new CsvBooking( + return CsvBooking.fromPersistence( ormEntity.id, ormEntity.userId, ormEntity.organizationId, diff --git a/apps/frontend/app/dashboard/documents/page.tsx b/apps/frontend/app/dashboard/documents/page.tsx index 7e913e6..d9b90e8 100644 --- a/apps/frontend/app/dashboard/documents/page.tsx +++ b/apps/frontend/app/dashboard/documents/page.tsx @@ -42,6 +42,15 @@ export default function UserDocumentsPage() { const [uploadingFiles, setUploadingFiles] = useState(false); const fileInputRef = useRef(null); + // Modal state for replacing documents + const [showReplaceModal, setShowReplaceModal] = useState(false); + const [documentToReplace, setDocumentToReplace] = useState(null); + const [replacingFile, setReplacingFile] = useState(false); + const replaceFileInputRef = useRef(null); + + // Dropdown menu state + const [openDropdownId, setOpenDropdownId] = useState(null); + // Helper function to get formatted quote number const getQuoteNumber = (booking: CsvBookingResponse): string => { return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; @@ -304,34 +313,76 @@ export default function UserDocumentsPage() { } }; - const handleDeleteDocument = async (bookingId: string, documentId: string, fileName: string) => { - if (!confirm(`Êtes-vous sûr de vouloir supprimer le document "${fileName}" ?`)) { + // Toggle dropdown menu + const toggleDropdown = (docId: string) => { + setOpenDropdownId(openDropdownId === docId ? null : docId); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = () => { + setOpenDropdownId(null); + }; + + if (openDropdownId) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [openDropdownId]); + + // Replace document handlers + const handleReplaceClick = (doc: DocumentWithBooking) => { + setOpenDropdownId(null); + setDocumentToReplace(doc); + setShowReplaceModal(true); + }; + + const handleCloseReplaceModal = () => { + setShowReplaceModal(false); + setDocumentToReplace(null); + if (replaceFileInputRef.current) { + replaceFileInputRef.current.value = ''; + } + }; + + const handleReplaceDocument = async () => { + if (!documentToReplace || !replaceFileInputRef.current?.files?.length) { + alert('Veuillez sélectionner un fichier de remplacement'); return; } + setReplacingFile(true); try { + const formData = new FormData(); + formData.append('document', replaceFileInputRef.current.files[0]); + const token = localStorage.getItem('access_token'); const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${bookingId}/documents/${documentId}`, + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`, { - method: 'DELETE', + method: 'PATCH', headers: { Authorization: `Bearer ${token}`, }, + body: formData, } ); if (!response.ok) { - throw new Error('Erreur lors de la suppression du document'); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Erreur lors du remplacement du document'); } - alert('Document supprimé avec succès!'); + alert('Document remplacé avec succès!'); + handleCloseReplaceModal(); fetchBookingsAndDocuments(); // Refresh the list } catch (error) { - console.error('Error deleting document:', error); + console.error('Error replacing document:', error); alert( - `Erreur lors de la suppression: ${error instanceof Error ? error.message : 'Erreur inconnue'}` + `Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}` ); + } finally { + setReplacingFile(false); } }; @@ -505,48 +556,76 @@ export default function UserDocumentsPage() { -
+
- {doc.status === 'PENDING' && ( - +
+ + +
+
)}
@@ -787,6 +866,116 @@ export default function UserDocumentsPage() { )} + + {/* Replace Document Modal */} + {showReplaceModal && documentToReplace && ( +
+
+ {/* Background overlay */} +
+ + {/* Modal panel */} +
+
+
+
+ + + +
+
+

+ Remplacer le document +

+
+ {/* Current document info */} +
+

Document actuel:

+

+ {documentToReplace.fileName} +

+

+ Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route} +

+
+ +
+ + +

+ Formats acceptés: PDF, Word, Excel, Images +

+
+
+
+
+
+
+ + +
+
+
+
+ )}
); }