898 lines
30 KiB
TypeScript
898 lines
30 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Get,
|
|
Patch,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
UploadedFiles,
|
|
Request,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
ParseIntPipe,
|
|
ParseUUIDPipe,
|
|
DefaultValuePipe,
|
|
Inject,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiConsumes,
|
|
ApiBody,
|
|
ApiBearerAuth,
|
|
ApiQuery,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { RolesGuard } from '../guards/roles.guard';
|
|
import { Roles } from '../decorators/roles.decorator';
|
|
import { Public } from '../decorators/public.decorator';
|
|
import { CsvBookingService } from '../services/csv-booking.service';
|
|
import { SubscriptionService } from '../services/subscription.service';
|
|
import {
|
|
ShipmentCounterPort,
|
|
SHIPMENT_COUNTER_PORT,
|
|
} from '@domain/ports/out/shipment-counter.port';
|
|
import {
|
|
OrganizationRepository,
|
|
ORGANIZATION_REPOSITORY,
|
|
} from '@domain/ports/out/organization.repository';
|
|
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
|
import {
|
|
CreateCsvBookingDto,
|
|
CsvBookingResponseDto,
|
|
CsvBookingListResponseDto,
|
|
CsvBookingStatsDto,
|
|
UpdateCsvBookingDetailsDto,
|
|
UpdateCsvBookingRateDto,
|
|
} from '../dto/csv-booking.dto';
|
|
|
|
/**
|
|
* 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,
|
|
private readonly subscriptionService: SubscriptionService,
|
|
private readonly configService: ConfigService,
|
|
@Inject(SHIPMENT_COUNTER_PORT)
|
|
private readonly shipmentCounter: ShipmentCounterPort,
|
|
@Inject(ORGANIZATION_REPOSITORY)
|
|
private readonly organizationRepository: OrganizationRepository
|
|
) {}
|
|
|
|
// ============================================================================
|
|
// STATIC ROUTES (must come FIRST)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Create a new CSV booking request
|
|
*
|
|
* POST /api/v1/csv-bookings
|
|
*/
|
|
@Post()
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@ApiBearerAuth()
|
|
@UseInterceptors(
|
|
FilesInterceptor('documents', 10, {
|
|
limits: {
|
|
fileSize: 10 * 1024 * 1024,
|
|
files: 10,
|
|
fields: 40,
|
|
parts: 50,
|
|
fieldSize: 64 * 1024,
|
|
},
|
|
})
|
|
)
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiOperation({
|
|
summary: 'Create a new CSV booking request',
|
|
description:
|
|
'Creates a new booking request from CSV rate selection. Uploads documents, sends email to carrier, and creates a notification for the user.',
|
|
})
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
required: [
|
|
'carrierName',
|
|
'carrierEmail',
|
|
'origin',
|
|
'destination',
|
|
'volumeCBM',
|
|
'weightKG',
|
|
'palletCount',
|
|
'priceUSD',
|
|
'priceEUR',
|
|
'primaryCurrency',
|
|
'transitDays',
|
|
'containerType',
|
|
],
|
|
properties: {
|
|
carrierName: { type: 'string', example: 'SSC Consolidation' },
|
|
carrierEmail: { type: 'string', format: 'email', example: 'bookings@sscconsolidation.com' },
|
|
origin: { type: 'string', example: 'NLRTM' },
|
|
destination: { type: 'string', example: 'USNYC' },
|
|
volumeCBM: { type: 'number', example: 25.5 },
|
|
weightKG: { type: 'number', example: 3500 },
|
|
palletCount: { type: 'number', example: 10 },
|
|
priceUSD: { type: 'number', example: 1850.5 },
|
|
priceEUR: { type: 'number', example: 1665.45 },
|
|
primaryCurrency: { type: 'string', enum: ['USD', 'EUR'], example: 'USD' },
|
|
transitDays: { type: 'number', example: 28 },
|
|
containerType: { type: 'string', example: 'LCL' },
|
|
notes: { type: 'string', example: 'Handle with care' },
|
|
documents: {
|
|
type: 'array',
|
|
items: { type: 'string', format: 'binary' },
|
|
description: 'Shipping documents (Bill of Lading, Packing List, Invoice, etc.)',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 201,
|
|
description: 'Booking created successfully',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Invalid request data or missing documents' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async createBooking(
|
|
@Body() dto: CreateCsvBookingDto,
|
|
@UploadedFiles() files: Express.Multer.File[],
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
// Debug: Log request details
|
|
if (!files || files.length === 0) {
|
|
throw new BadRequestException('At least one document is required');
|
|
}
|
|
|
|
// Validate user authentication
|
|
if (!req.user || !req.user.id) {
|
|
throw new BadRequestException('User authentication failed - no user info in request');
|
|
}
|
|
|
|
if (!req.user.organizationId) {
|
|
throw new BadRequestException('Organization ID is required');
|
|
}
|
|
|
|
const userId = req.user.id;
|
|
const organizationId = req.user.organizationId;
|
|
|
|
// ADMIN users bypass shipment limits
|
|
if (req.user.role !== 'ADMIN') {
|
|
// Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year)
|
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
|
const maxShipments = subscription.maxShipmentsPerYear;
|
|
if (maxShipments !== -1) {
|
|
const currentYear = new Date().getFullYear();
|
|
const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
|
|
organizationId,
|
|
currentYear
|
|
);
|
|
if (count >= maxShipments) {
|
|
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert string values to numbers (multipart/form-data sends everything as strings)
|
|
const sanitizedDto: CreateCsvBookingDto = {
|
|
...dto,
|
|
volumeCBM: typeof dto.volumeCBM === 'string' ? parseFloat(dto.volumeCBM) : dto.volumeCBM,
|
|
weightKG: typeof dto.weightKG === 'string' ? parseFloat(dto.weightKG) : dto.weightKG,
|
|
palletCount:
|
|
typeof dto.palletCount === 'string' ? parseInt(dto.palletCount, 10) : dto.palletCount,
|
|
priceUSD: typeof dto.priceUSD === 'string' ? parseFloat(dto.priceUSD) : dto.priceUSD,
|
|
priceEUR: typeof dto.priceEUR === 'string' ? parseFloat(dto.priceEUR) : dto.priceEUR,
|
|
transitDays:
|
|
typeof dto.transitDays === 'string' ? parseInt(dto.transitDays, 10) : dto.transitDays,
|
|
};
|
|
|
|
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<CsvBookingListResponseDto> {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
|
}
|
|
|
|
/**
|
|
* Get the reservation (paid shipment) quota for the current organization.
|
|
*
|
|
* Uses the organization's real plan (not the ADMIN PLATINIUM override) and
|
|
* counts only PAID shipments this year, so the UI can show an upgrade prompt.
|
|
*
|
|
* GET /api/v1/csv-bookings/reservation-quota
|
|
*/
|
|
@Get('reservation-quota')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({ summary: 'Get the paid-reservation quota for the current organization' })
|
|
@ApiResponse({ status: 200, description: 'Quota retrieved successfully' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async getReservationQuota(
|
|
@Request() req: any
|
|
): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> {
|
|
const organizationId = req.user.organizationId;
|
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
|
const max = subscription.maxShipmentsPerYear;
|
|
const unlimited = max === -1;
|
|
const currentYear = new Date().getFullYear();
|
|
const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
|
|
organizationId,
|
|
currentYear
|
|
);
|
|
return { max, used, unlimited, limitReached: !unlimited && used >= max };
|
|
}
|
|
|
|
/**
|
|
* 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<CsvBookingStatsDto> {
|
|
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(RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER')
|
|
@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<CsvBookingStatsDto> {
|
|
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(RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Get organization bookings',
|
|
description:
|
|
"Retrieve all bookings and quotes of the user's organization, whoever created them, " +
|
|
'with the name of their creator. Available to every member of the organization.',
|
|
})
|
|
@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<CsvBookingListResponseDto> {
|
|
const organizationId = req.user.organizationId;
|
|
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
|
}
|
|
|
|
/**
|
|
* Accept a booking request (PUBLIC - token-based)
|
|
*
|
|
* GET /api/v1/csv-bookings/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.',
|
|
})
|
|
@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) {
|
|
// Accept the booking
|
|
const booking = await this.csvBookingService.acceptBooking(token);
|
|
|
|
// Return simple success response
|
|
return {
|
|
success: true,
|
|
bookingId: booking.id,
|
|
action: 'accepted',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reject a booking request (PUBLIC - token-based)
|
|
*
|
|
* GET /api/v1/csv-bookings/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.',
|
|
})
|
|
@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) {
|
|
// Reject the booking
|
|
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
|
|
|
// Return simple success response
|
|
return {
|
|
success: true,
|
|
bookingId: booking.id,
|
|
action: 'rejected',
|
|
reason: reason || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create Stripe Checkout session for commission payment
|
|
*
|
|
* POST /api/v1/csv-bookings/:id/pay
|
|
*/
|
|
@Post(':id/pay')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Pay commission for a booking',
|
|
description:
|
|
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Stripe checkout session created',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
sessionUrl: { type: 'string' },
|
|
sessionId: { type: 'string' },
|
|
commissionAmountEur: { type: 'number' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Booking not in QUOTE status' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async payCommission(@Param('id') id: string, @Request() req: any) {
|
|
const userId = req.user.id;
|
|
const userEmail = req.user.email;
|
|
const organizationId = req.user.organizationId;
|
|
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
|
|
|
|
// ADMIN users bypass SIRET verification
|
|
if (req.user.role !== 'ADMIN') {
|
|
// SIRET verification gate: organization must have a verified SIRET before paying
|
|
const organization = await this.organizationRepository.findById(organizationId);
|
|
if (!organization || !organization.siretVerified) {
|
|
throw new ForbiddenException(
|
|
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.'
|
|
);
|
|
}
|
|
}
|
|
|
|
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
|
|
}
|
|
|
|
/**
|
|
* Confirm commission payment after Stripe redirect
|
|
*
|
|
* POST /api/v1/csv-bookings/:id/confirm-payment
|
|
*/
|
|
@Post(':id/confirm-payment')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Confirm commission payment',
|
|
description:
|
|
'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
required: ['sessionId'],
|
|
properties: {
|
|
sessionId: { type: 'string', description: 'Stripe Checkout session ID' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Payment confirmed, booking activated',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async confirmPayment(
|
|
@Param('id') id: string,
|
|
@Body('sessionId') sessionId: string,
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
if (!sessionId) {
|
|
throw new BadRequestException('sessionId is required');
|
|
}
|
|
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
|
|
}
|
|
|
|
/**
|
|
* Declare bank transfer — user confirms they have sent the wire transfer
|
|
*
|
|
* POST /api/v1/csv-bookings/:id/declare-transfer
|
|
*/
|
|
@Post(':id/declare-transfer')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Declare bank transfer',
|
|
description:
|
|
'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Bank transfer declared, booking awaiting admin validation',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Booking not in QUOTE status' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async declareTransfer(
|
|
@Param('id') id: string,
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.declareBankTransfer(id, userId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Get booking by ID',
|
|
description:
|
|
'Retrieve a specific CSV booking by its ID. Readable by any member of the booking organization ' +
|
|
'and by the assigned carrier; changes remain reserved to the booking owner.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Booking retrieved successfully',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async getBooking(@Param('id') id: string, @Request() req: any): Promise<CsvBookingResponseDto> {
|
|
const userId = req.user.id;
|
|
const carrierId = req.user.carrierId; // May be undefined if not a carrier
|
|
return await this.csvBookingService.getBookingById(
|
|
id,
|
|
userId,
|
|
carrierId,
|
|
req.user.organizationId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cancel a booking (user action)
|
|
*
|
|
* PATCH /api/v1/csv-bookings/:id/cancel
|
|
*/
|
|
@Patch(':id/cancel')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Cancel booking',
|
|
description: 'Cancel a pending booking. Only accessible by the booking owner.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Booking cancelled successfully',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
@ApiResponse({ status: 400, description: 'Booking cannot be cancelled (already accepted)' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async cancelBooking(
|
|
@Param('id') id: string,
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.cancelBooking(id, userId);
|
|
}
|
|
|
|
/**
|
|
* Delete an unpaid booking
|
|
*
|
|
* DELETE /api/v1/csv-bookings/:id
|
|
*/
|
|
@Delete(':id')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Delete an unpaid booking',
|
|
description:
|
|
'Permanently deletes a booking whose commission has not been paid. Only accessible by the booking owner. A paid booking has been sent to the carrier and can only be cancelled.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
|
|
@ApiResponse({ status: 400, description: 'Booking has been paid and cannot be deleted' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async deleteBooking(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Request() req: any
|
|
): Promise<{ success: boolean; message: string }> {
|
|
return await this.csvBookingService.deleteBooking(id, req.user.id);
|
|
}
|
|
|
|
/**
|
|
* Update booking cargo details before payment
|
|
*
|
|
* PATCH /api/v1/csv-bookings/:id/details
|
|
*/
|
|
@Patch(':id/details')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Update booking details before payment',
|
|
description:
|
|
'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is QUOTE.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Booking details updated successfully',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async updateBookingDetails(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateCsvBookingDetailsDto,
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.updateBookingDetails(id, userId, dto);
|
|
}
|
|
|
|
/**
|
|
* Re-apply a full rate selection before payment
|
|
*
|
|
* PATCH /api/v1/csv-bookings/:id/rate
|
|
*/
|
|
@Patch(':id/rate')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Update booking rate/route before payment',
|
|
description:
|
|
'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a QUOTE booking. Only the owner can edit.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Booking rate updated successfully',
|
|
type: CsvBookingResponseDto,
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async updateBookingRate(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateCsvBookingRateDto,
|
|
@Request() req: any
|
|
): Promise<CsvBookingResponseDto> {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.updateBookingRate(id, userId, dto);
|
|
}
|
|
|
|
/**
|
|
* Add documents to an existing booking
|
|
*
|
|
* POST /api/v1/csv-bookings/:id/documents
|
|
*/
|
|
@Post(':id/documents')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@UseInterceptors(
|
|
FilesInterceptor('documents', 10, {
|
|
limits: {
|
|
fileSize: 10 * 1024 * 1024,
|
|
files: 10,
|
|
fields: 40,
|
|
parts: 50,
|
|
fieldSize: 64 * 1024,
|
|
},
|
|
})
|
|
)
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiOperation({
|
|
summary: 'Add documents to an existing booking',
|
|
description:
|
|
'Upload additional documents to a pending booking. Only the booking owner can add documents.',
|
|
})
|
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
documents: {
|
|
type: 'array',
|
|
items: { type: 'string', format: 'binary' },
|
|
description: 'Documents to add (max 10 files)',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Documents added successfully',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', example: true },
|
|
message: { type: 'string', example: 'Documents added successfully' },
|
|
documentsAdded: { type: 'number', example: 2 },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
async addDocuments(
|
|
@Param('id') id: string,
|
|
@UploadedFiles() files: Express.Multer.File[],
|
|
@Request() req: any
|
|
) {
|
|
if (!files || files.length === 0) {
|
|
throw new BadRequestException('At least one document is required');
|
|
}
|
|
|
|
const userId = req.user.id;
|
|
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, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@UseInterceptors(
|
|
FilesInterceptor('document', 1, {
|
|
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 10, parts: 11, fieldSize: 64 * 1024 },
|
|
})
|
|
)
|
|
@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
|
|
*
|
|
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
|
*/
|
|
@Delete(':bookingId/documents/:documentId')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN', 'MANAGER', 'USER')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Delete a document from a booking',
|
|
description:
|
|
'Remove a document from a pending booking. Only the booking owner can delete documents.',
|
|
})
|
|
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
|
@ApiParam({ name: 'documentId', description: 'Document ID (UUID)' })
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Document deleted successfully',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', example: true },
|
|
message: { type: 'string', example: 'Document deleted successfully' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
|
async deleteDocument(
|
|
@Param('bookingId') bookingId: string,
|
|
@Param('documentId') documentId: string,
|
|
@Request() req: any
|
|
) {
|
|
const userId = req.user.id;
|
|
return await this.csvBookingService.deleteDocument(bookingId, documentId, userId);
|
|
}
|
|
}
|