diff --git a/.claude/settings.local.json b/.claude/settings.local.json index eae7961..dbabd69 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,9 @@ "Bash(xargs -r docker rm:*)", "Bash(npm run migration:run:*)", "Bash(npm run dev:*)", - "Bash(npm run backend:dev:*)" + "Bash(npm run backend:dev:*)", + "Bash(env -i PATH=\"$PATH\" HOME=\"$HOME\" node:*)", + "Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -U xpeditis -d xpeditis_dev -c:*)" ], "deny": [], "ask": [] diff --git a/apps/backend/create-test-booking.js b/apps/backend/create-test-booking.js index f2cd442..cf0bd73 100644 --- a/apps/backend/create-test-booking.js +++ b/apps/backend/create-test-booking.js @@ -24,15 +24,46 @@ async function createTestBooking() { const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; + // Create dummy documents in JSONB format + const dummyDocuments = JSON.stringify([ + { + id: uuidv4(), + type: 'BILL_OF_LADING', + fileName: 'bill-of-lading.pdf', + filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', + mimeType: 'application/pdf', + size: 102400, // 100KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'PACKING_LIST', + fileName: 'packing-list.pdf', + filePath: 'https://dummy-storage.com/documents/packing-list.pdf', + mimeType: 'application/pdf', + size: 51200, // 50KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'COMMERCIAL_INVOICE', + fileName: 'commercial-invoice.pdf', + filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', + mimeType: 'application/pdf', + size: 76800, // 75KB + uploadedAt: new Date().toISOString(), + }, + ]); + const query = ` INSERT INTO csv_bookings ( id, user_id, organization_id, carrier_name, carrier_email, origin, destination, volume_cbm, weight_kg, pallet_count, price_usd, price_eur, primary_currency, transit_days, container_type, - status, confirmation_token, requested_at, notes + status, confirmation_token, requested_at, notes, documents ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, NOW(), $18 + $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 ) RETURNING id, confirmation_token; `; @@ -55,6 +86,7 @@ async function createTestBooking() { 'PENDING', // status - IMPORTANT! confirmationToken, 'Test booking created by script', + dummyDocuments, // documents JSONB ]; const result = await client.query(query, values); diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts new file mode 100644 index 0000000..e9e29a9 --- /dev/null +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -0,0 +1,125 @@ +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 { CarrierAuthService } from '../services/carrier-auth.service'; + +/** + * CSV Booking Actions Controller (Public Routes) + * + * Handles public accept/reject actions from carrier emails + * Separated from main controller to avoid routing conflicts + */ +@ApiTags('CSV Booking Actions') +@Controller('csv-booking-actions') +export class CsvBookingActionsController { + constructor( + private readonly csvBookingService: CsvBookingService, + private readonly carrierAuthService: CarrierAuthService + ) {} + + /** + * Accept a booking request (PUBLIC - token-based) + * + * GET /api/v1/csv-booking-actions/accept/:token + */ + @Public() + @Get('accept/:token') + @ApiOperation({ + summary: 'Accept booking request (public)', + description: + 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking accepted successfully. Returns auto-login token and booking details.', + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ + status: 400, + description: 'Booking cannot be accepted (invalid status or expired)', + }) + async acceptBooking(@Param('token') token: string) { + // 1. Accept the booking + const booking = await this.csvBookingService.acceptBooking(token); + + // 2. Create carrier account if it doesn't exist + const { carrierId, userId, isNewAccount } = + await this.carrierAuthService.createCarrierAccountIfNotExists( + booking.carrierEmail, + booking.carrierName + ); + + // 3. Link the booking to the carrier + await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); + + // 4. Generate auto-login token + const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); + + // 5. Return JSON response for frontend to handle + return { + success: true, + autoLoginToken, + bookingId: booking.id, + isNewAccount, + action: 'accepted', + }; + } + + /** + * Reject a booking request (PUBLIC - token-based) + * + * GET /api/v1/csv-booking-actions/reject/:token + */ + @Public() + @Get('reject/:token') + @ApiOperation({ + summary: 'Reject booking request (public)', + description: + 'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiQuery({ + name: 'reason', + required: false, + description: 'Rejection reason', + example: 'No capacity available', + }) + @ApiResponse({ + status: 200, + description: 'Booking rejected successfully. Returns auto-login token and booking details.', + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ + status: 400, + description: 'Booking cannot be rejected (invalid status or expired)', + }) + async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) { + // 1. Reject the booking + const booking = await this.csvBookingService.rejectBooking(token, reason); + + // 2. Create carrier account if it doesn't exist + const { carrierId, userId, isNewAccount } = + await this.carrierAuthService.createCarrierAccountIfNotExists( + booking.carrierEmail, + booking.carrierName + ); + + // 3. Link the booking to the carrier + await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); + + // 4. Generate auto-login token + const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); + + // 5. Return JSON response for frontend to handle + return { + success: true, + autoLoginToken, + bookingId: booking.id, + isNewAccount, + action: 'rejected', + reason: reason || null, + }; + } +} diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index 852dd75..db05f59 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CsvBookingsController } from './controllers/csv-bookings.controller'; +import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; import { CsvBookingService } from './services/csv-booking.service'; import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; @@ -22,7 +23,7 @@ import { CarrierPortalModule } from './modules/carrier-portal.module'; StorageModule, forwardRef(() => CarrierPortalModule), // Import CarrierPortalModule to access CarrierAuthService ], - controllers: [CsvBookingsController], + controllers: [CsvBookingsController, CsvBookingActionsController], providers: [CsvBookingService, TypeOrmCsvBookingRepository], exports: [CsvBookingService], }) diff --git a/apps/backend/src/application/services/carrier-auth.service.ts b/apps/backend/src/application/services/carrier-auth.service.ts index c819394..938d4c5 100644 --- a/apps/backend/src/application/services/carrier-auth.service.ts +++ b/apps/backend/src/application/services/carrier-auth.service.ts @@ -58,7 +58,9 @@ export class CarrierAuthService { } // Create new organization for the carrier + const organizationId = uuidv4(); // Generate UUID for organization const organization = this.organizationRepository.create({ + id: organizationId, // Provide explicit ID since @PrimaryColumn requires it name: carrierName, type: 'CARRIER', isCarrier: true, 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 2184943..778685f 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 @@ -33,7 +33,7 @@ export class CsvBookingMapper { ormEntity.transitDays, ormEntity.containerType, CsvBookingStatus[ormEntity.status] as CsvBookingStatus, - ormEntity.documents as CsvBookingDocument[], + (ormEntity.documents || []) as CsvBookingDocument[], // Ensure documents is always an array ormEntity.confirmationToken, ormEntity.requestedAt, ormEntity.respondedAt, diff --git a/apps/frontend/app/carrier/accept/[token]/page.tsx b/apps/frontend/app/carrier/accept/[token]/page.tsx index 0651de2..28c618f 100644 --- a/apps/frontend/app/carrier/accept/[token]/page.tsx +++ b/apps/frontend/app/carrier/accept/[token]/page.tsx @@ -32,7 +32,7 @@ export default function CarrierAcceptPage() { try { // Appeler l'API backend pour accepter le booking - const response = await fetch(`http://localhost:4000/api/v1/csv-bookings/accept/${token}`, { + const response = await fetch(`http://localhost:4000/api/v1/csv-booking-actions/accept/${token}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -40,17 +40,25 @@ export default function CarrierAcceptPage() { }); if (!response.ok) { - const errorData = await response.json(); + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = { message: `Erreur HTTP ${response.status}` }; + } // Messages d'erreur personnalisés let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking'; - if (errorMessage.includes('status ACCEPTED')) { - errorMessage = 'Ce booking a déjà été accepté. Vous ne pouvez pas l\'accepter à nouveau.'; + // Log pour debug + console.error('API Error:', errorMessage); + + if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) { + errorMessage = '⚠️ Ce booking a déjà été accepté.\n\nVous devez créer un NOUVEAU booking avec:\ncd apps/backend\nnode create-test-booking.js'; } else if (errorMessage.includes('status REJECTED')) { errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas l\'accepter.'; - } else if (errorMessage.includes('not found')) { - errorMessage = 'Booking introuvable. Le lien peut avoir expiré.'; + } else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { + errorMessage = 'Booking introuvable. Le lien peut avoir expiré ou le token est invalide.'; } throw new Error(errorMessage); @@ -67,10 +75,7 @@ export default function CarrierAcceptPage() { setIsNewAccount(data.isNewAccount); setLoading(false); - // Rediriger vers la page de détails après 2 secondes - setTimeout(() => { - router.push(`/carrier/dashboard/bookings/${data.bookingId}`); - }, 2000); + // Redirection manuelle - plus de redirection automatique } catch (err) { console.error('Error accepting booking:', err); setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation'); @@ -140,14 +145,13 @@ export default function CarrierAcceptPage() { )} -
Redirection vers les détails de la réservation...
-Redirection vers les détails de la réservation...
-