316 lines
8.9 KiB
TypeScript
316 lines
8.9 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Param,
|
|
Body,
|
|
Query,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Logger,
|
|
UsePipes,
|
|
ValidationPipe,
|
|
NotFoundException,
|
|
ParseUUIDPipe,
|
|
ParseIntPipe,
|
|
DefaultValuePipe,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBadRequestResponse,
|
|
ApiNotFoundResponse,
|
|
ApiInternalServerErrorResponse,
|
|
ApiQuery,
|
|
ApiParam,
|
|
ApiBearerAuth,
|
|
} from '@nestjs/swagger';
|
|
import {
|
|
CreateBookingRequestDto,
|
|
BookingResponseDto,
|
|
BookingListResponseDto,
|
|
} from '../dto';
|
|
import { BookingMapper } from '../mappers';
|
|
import { BookingService } from '../../domain/services/booking.service';
|
|
import { BookingRepository } from '../../domain/ports/out/booking.repository';
|
|
import { RateQuoteRepository } from '../../domain/ports/out/rate-quote.repository';
|
|
import { BookingNumber } from '../../domain/value-objects/booking-number.vo';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
|
|
@ApiTags('Bookings')
|
|
@Controller('api/v1/bookings')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
export class BookingsController {
|
|
private readonly logger = new Logger(BookingsController.name);
|
|
|
|
constructor(
|
|
private readonly bookingService: BookingService,
|
|
private readonly bookingRepository: BookingRepository,
|
|
private readonly rateQuoteRepository: RateQuoteRepository,
|
|
) {}
|
|
|
|
@Post()
|
|
@HttpCode(HttpStatus.CREATED)
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
@ApiOperation({
|
|
summary: 'Create a new booking',
|
|
description:
|
|
'Create a new booking based on a rate quote. The booking will be in "draft" status initially. Requires authentication.',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.CREATED,
|
|
description: 'Booking created successfully',
|
|
type: BookingResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiBadRequestResponse({
|
|
description: 'Invalid request parameters',
|
|
})
|
|
@ApiNotFoundResponse({
|
|
description: 'Rate quote not found',
|
|
})
|
|
@ApiInternalServerErrorResponse({
|
|
description: 'Internal server error',
|
|
})
|
|
async createBooking(
|
|
@Body() dto: CreateBookingRequestDto,
|
|
@CurrentUser() user: UserPayload,
|
|
): Promise<BookingResponseDto> {
|
|
this.logger.log(
|
|
`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`,
|
|
);
|
|
|
|
try {
|
|
// Convert DTO to domain input, using authenticated user's data
|
|
const input = {
|
|
...BookingMapper.toCreateBookingInput(dto),
|
|
userId: user.id,
|
|
organizationId: user.organizationId,
|
|
};
|
|
|
|
// Create booking via domain service
|
|
const booking = await this.bookingService.createBooking(input);
|
|
|
|
// Fetch rate quote for response
|
|
const rateQuote = await this.rateQuoteRepository.findById(dto.rateQuoteId);
|
|
if (!rateQuote) {
|
|
throw new NotFoundException(`Rate quote ${dto.rateQuoteId} not found`);
|
|
}
|
|
|
|
// Convert to DTO
|
|
const response = BookingMapper.toDto(booking, rateQuote);
|
|
|
|
this.logger.log(
|
|
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`,
|
|
);
|
|
|
|
return response;
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
|
error?.stack,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@Get(':id')
|
|
@ApiOperation({
|
|
summary: 'Get booking by ID',
|
|
description:
|
|
'Retrieve detailed information about a specific booking. Requires authentication.',
|
|
})
|
|
@ApiParam({
|
|
name: 'id',
|
|
description: 'Booking ID (UUID)',
|
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'Booking details retrieved successfully',
|
|
type: BookingResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiNotFoundResponse({
|
|
description: 'Booking not found',
|
|
})
|
|
async getBooking(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@CurrentUser() user: UserPayload,
|
|
): Promise<BookingResponseDto> {
|
|
this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`);
|
|
|
|
const booking = await this.bookingRepository.findById(id);
|
|
if (!booking) {
|
|
throw new NotFoundException(`Booking ${id} not found`);
|
|
}
|
|
|
|
// Verify booking belongs to user's organization
|
|
if (booking.organizationId !== user.organizationId) {
|
|
throw new NotFoundException(`Booking ${id} not found`);
|
|
}
|
|
|
|
// Fetch rate quote
|
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
|
if (!rateQuote) {
|
|
throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`);
|
|
}
|
|
|
|
return BookingMapper.toDto(booking, rateQuote);
|
|
}
|
|
|
|
@Get('number/:bookingNumber')
|
|
@ApiOperation({
|
|
summary: 'Get booking by booking number',
|
|
description:
|
|
'Retrieve detailed information about a specific booking using its booking number. Requires authentication.',
|
|
})
|
|
@ApiParam({
|
|
name: 'bookingNumber',
|
|
description: 'Booking number',
|
|
example: 'WCM-2025-ABC123',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'Booking details retrieved successfully',
|
|
type: BookingResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiNotFoundResponse({
|
|
description: 'Booking not found',
|
|
})
|
|
async getBookingByNumber(
|
|
@Param('bookingNumber') bookingNumber: string,
|
|
@CurrentUser() user: UserPayload,
|
|
): Promise<BookingResponseDto> {
|
|
this.logger.log(
|
|
`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`,
|
|
);
|
|
|
|
const bookingNumberVo = BookingNumber.fromString(bookingNumber);
|
|
const booking =
|
|
await this.bookingRepository.findByBookingNumber(bookingNumberVo);
|
|
|
|
if (!booking) {
|
|
throw new NotFoundException(`Booking ${bookingNumber} not found`);
|
|
}
|
|
|
|
// Verify booking belongs to user's organization
|
|
if (booking.organizationId !== user.organizationId) {
|
|
throw new NotFoundException(`Booking ${bookingNumber} not found`);
|
|
}
|
|
|
|
// Fetch rate quote
|
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
|
if (!rateQuote) {
|
|
throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`);
|
|
}
|
|
|
|
return BookingMapper.toDto(booking, rateQuote);
|
|
}
|
|
|
|
@Get()
|
|
@ApiOperation({
|
|
summary: 'List bookings',
|
|
description:
|
|
"Retrieve a paginated list of bookings for the authenticated user's organization. Requires authentication.",
|
|
})
|
|
@ApiQuery({
|
|
name: 'page',
|
|
required: false,
|
|
description: 'Page number (1-based)',
|
|
example: 1,
|
|
})
|
|
@ApiQuery({
|
|
name: 'pageSize',
|
|
required: false,
|
|
description: 'Number of items per page',
|
|
example: 20,
|
|
})
|
|
@ApiQuery({
|
|
name: 'status',
|
|
required: false,
|
|
description: 'Filter by booking status',
|
|
enum: [
|
|
'draft',
|
|
'pending_confirmation',
|
|
'confirmed',
|
|
'in_transit',
|
|
'delivered',
|
|
'cancelled',
|
|
],
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'Bookings list retrieved successfully',
|
|
type: BookingListResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
async listBookings(
|
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
|
@Query('status') status: string | undefined,
|
|
@CurrentUser() user: UserPayload,
|
|
): Promise<BookingListResponseDto> {
|
|
this.logger.log(
|
|
`[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`,
|
|
);
|
|
|
|
// Use authenticated user's organization ID
|
|
const organizationId = user.organizationId;
|
|
|
|
// Fetch bookings for the user's organization
|
|
const bookings =
|
|
await this.bookingRepository.findByOrganization(organizationId);
|
|
|
|
// Filter by status if provided
|
|
const filteredBookings = status
|
|
? bookings.filter((b: any) => b.status.value === status)
|
|
: bookings;
|
|
|
|
// Paginate
|
|
const startIndex = (page - 1) * pageSize;
|
|
const endIndex = startIndex + pageSize;
|
|
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
|
|
|
// Fetch rate quotes for all bookings
|
|
const bookingsWithQuotes = await Promise.all(
|
|
paginatedBookings.map(async (booking: any) => {
|
|
const rateQuote = await this.rateQuoteRepository.findById(
|
|
booking.rateQuoteId,
|
|
);
|
|
return { booking, rateQuote: rateQuote! };
|
|
}),
|
|
);
|
|
|
|
// Convert to DTOs
|
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
|
|
|
const totalPages = Math.ceil(filteredBookings.length / pageSize);
|
|
|
|
return {
|
|
bookings: bookingDtos,
|
|
total: filteredBookings.length,
|
|
page,
|
|
pageSize,
|
|
totalPages,
|
|
};
|
|
}
|
|
}
|