feature fix branch

This commit is contained in:
David 2025-12-12 10:31:49 +01:00
parent 4279cd291d
commit faf1207300
8 changed files with 198 additions and 36 deletions

View File

@ -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": []

View File

@ -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);

View File

@ -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,
};
}
}

View File

@ -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],
})

View File

@ -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,

View File

@ -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,

View File

@ -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() {
</div>
)}
<div className="flex items-center justify-center text-gray-600 mb-6">
<Truck className="w-5 h-5 mr-2 animate-bounce" />
<p>Redirection vers les détails de la réservation...</p>
</div>
<div className="text-sm text-gray-500">
Vous serez automatiquement redirigé dans 2 secondes
</div>
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors flex items-center justify-center"
>
<Truck className="w-5 h-5 mr-2" />
Voir les détails de la réservation
</button>
</div>
</div>
);

View File

@ -35,7 +35,7 @@ export default function CarrierRejectPage() {
try {
// Construire l'URL avec la raison en query param si fournie
const url = new URL(`http://localhost:4000/api/v1/csv-bookings/reject/${token}`);
const url = new URL(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`);
if (reason.trim()) {
url.searchParams.append('reason', reason.trim());
}
@ -76,10 +76,7 @@ export default function CarrierRejectPage() {
setShowSuccess(true);
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 rejecting booking:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
@ -134,14 +131,13 @@ export default function CarrierRejectPage() {
</div>
)}
<div className="flex items-center justify-center text-gray-600 mb-6">
<Truck className="w-5 h-5 mr-2 animate-bounce" />
<p>Redirection vers les détails de la réservation...</p>
</div>
<div className="text-sm text-gray-500">
Vous serez automatiquement redirigé dans 2 secondes
</div>
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors flex items-center justify-center"
>
<Truck className="w-5 h-5 mr-2" />
Voir les détails de la réservation
</button>
</div>
</div>
);