xpeditis2.0/apps/backend/src/application/controllers/csv-bookings.controller.ts

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