diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index cc37eee..353a9fb 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -69,7 +69,16 @@ export class ControllerRoute { 'Retrieve the list of all regions registered in Syncrow.'; }; }; + static BOOKABLE_SPACES = class { + public static readonly ROUTE = 'bookable-spaces'; + static ACTIONS = class { + public static readonly ADD_BOOKABLE_SPACES_SUMMARY = + 'Add new bookable spaces'; + public static readonly ADD_BOOKABLE_SPACES_DESCRIPTION = + 'This endpoint allows you to add new bookable spaces by providing the required details.'; + }; + }; static COMMUNITY = class { public static readonly ROUTE = '/projects/:projectUuid/communities'; static ACTIONS = class { diff --git a/libs/common/src/helper/timeToMinutes.ts b/libs/common/src/helper/timeToMinutes.ts new file mode 100644 index 0000000..8bf0e9b --- /dev/null +++ b/libs/common/src/helper/timeToMinutes.ts @@ -0,0 +1,5 @@ +// Convert time string (HH:mm) to minutes +export function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +} diff --git a/src/app.module.ts b/src/app.module.ts index ce64932..6bc32bd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -39,6 +39,7 @@ import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston import { AqiModule } from './aqi/aqi.module'; import { OccupancyModule } from './occupancy/occupancy.module'; import { WeatherModule } from './weather/weather.module'; +import { BookingModule } from './booking'; @Module({ imports: [ ConfigModule.forRoot({ @@ -82,6 +83,7 @@ import { WeatherModule } from './weather/weather.module'; OccupancyModule, WeatherModule, AqiModule, + BookingModule, ], providers: [ { diff --git a/src/booking/booking.module.ts b/src/booking/booking.module.ts new file mode 100644 index 0000000..6ce9c9a --- /dev/null +++ b/src/booking/booking.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookableSpaceController } from './controllers'; +import { BookableSpaceService } from './services'; +import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories'; +import { BookableSpaceEntity } from '@app/common/modules/booking/entities'; +import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([BookableSpaceEntity, SpaceEntity])], + controllers: [BookableSpaceController], + providers: [BookableSpaceService, BookableSpaceEntityRepository], + exports: [BookableSpaceService], +}) +export class BookingModule {} diff --git a/src/booking/controllers/bookable-space.controller.ts b/src/booking/controllers/bookable-space.controller.ts new file mode 100644 index 0000000..08b5179 --- /dev/null +++ b/src/booking/controllers/bookable-space.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; +import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { ControllerRoute } from '@app/common/constants/controller-route'; + +import { CreateBookableSpaceDto } from '../dtos'; +import { BookableSpaceService } from '../services'; + +@ApiTags('Booking Module') +@Controller({ + version: EnableDisableStatusEnum.ENABLED, + path: ControllerRoute.BOOKABLE_SPACES.ROUTE, +}) +export class BookableSpaceController { + constructor(private readonly bookableSpaceService: BookableSpaceService) {} + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post() + @ApiOperation({ + summary: + ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_SUMMARY, + description: + ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_DESCRIPTION, + }) + async create(@Body() dto: CreateBookableSpaceDto): Promise { + return this.bookableSpaceService.create(dto); + } +} diff --git a/src/booking/controllers/index.ts b/src/booking/controllers/index.ts new file mode 100644 index 0000000..d239862 --- /dev/null +++ b/src/booking/controllers/index.ts @@ -0,0 +1 @@ +export * from './bookable-space.controller'; diff --git a/src/booking/dtos/create-bookable-space.dto.ts b/src/booking/dtos/create-bookable-space.dto.ts new file mode 100644 index 0000000..a8d88ba --- /dev/null +++ b/src/booking/dtos/create-bookable-space.dto.ts @@ -0,0 +1,75 @@ +// dtos/bookable-space.dto.ts +import { DaysEnum } from '@app/common/constants/days.enum'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsString, + IsUUID, + IsInt, + ValidateNested, + ArrayMinSize, + ArrayMaxSize, + Min, + Max, + Matches, +} from 'class-validator'; + +export class BookingSlotDto { + @ApiProperty({ + enum: DaysEnum, + isArray: true, + example: [DaysEnum.MON, DaysEnum.WED, DaysEnum.FRI], + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least one day must be selected' }) + @ArrayMaxSize(7, { message: 'Cannot select more than 7 days' }) + @IsEnum(DaysEnum, { each: true, message: 'Invalid day provided' }) + daysAvailable: DaysEnum[]; + + @ApiProperty({ example: '09:00' }) + @IsString() + @IsNotEmpty({ message: 'Start time cannot be empty' }) + @Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'Start time must be in HH:mm format (24-hour)', + }) + startTime: string; + + @ApiProperty({ example: '17:00' }) + @IsString() + @IsNotEmpty({ message: 'End time cannot be empty' }) + @Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'End time must be in HH:mm format (24-hour)', + }) + endTime: string; + + @ApiProperty({ example: 10, minimum: 0 }) + @IsInt() + @Min(0, { message: 'Points cannot be negative' }) + @Max(1000, { message: 'Points cannot exceed 1000' }) + points: number; +} + +export class CreateBookableSpaceDto { + @ApiProperty({ + type: 'string', + isArray: true, + example: [ + '3fa85f64-5717-4562-b3fc-2c963f66afa6', + '4fa85f64-5717-4562-b3fc-2c963f66afa7', + ], + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least one space must be selected' }) + @IsUUID('all', { each: true, message: 'Invalid space UUID provided' }) + spaceUuids: string[]; + + @ApiProperty({ type: BookingSlotDto, isArray: true }) + @IsArray() + @ArrayMinSize(1, { message: 'At least one booking slot must be provided' }) + @ValidateNested({ each: true }) + @Type(() => BookingSlotDto) + slots: BookingSlotDto[]; +} diff --git a/src/booking/dtos/index.ts b/src/booking/dtos/index.ts new file mode 100644 index 0000000..c56d66c --- /dev/null +++ b/src/booking/dtos/index.ts @@ -0,0 +1 @@ +export * from './create-bookable-space.dto'; diff --git a/src/booking/index.ts b/src/booking/index.ts new file mode 100644 index 0000000..770d226 --- /dev/null +++ b/src/booking/index.ts @@ -0,0 +1 @@ +export * from './booking.module'; diff --git a/src/booking/services/bookable-space.service.ts b/src/booking/services/bookable-space.service.ts new file mode 100644 index 0000000..57b573a --- /dev/null +++ b/src/booking/services/bookable-space.service.ts @@ -0,0 +1,135 @@ +// services/bookable-space.service.ts +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { BookingSlotDto, CreateBookableSpaceDto } from '../dtos'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { BookableSpaceEntity } from '@app/common/modules/booking/entities'; +import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; +import { timeToMinutes } from '@app/common/helper/timeToMinutes'; + +@Injectable() +export class BookableSpaceService { + constructor( + @InjectRepository(BookableSpaceEntity) + private bookableRepo: Repository, + + @InjectRepository(SpaceEntity) + private spaceRepo: Repository, + ) {} + + async create(dto: CreateBookableSpaceDto): Promise { + // Validate time slots first + this.validateTimeSlots(dto.slots); + + // Validate spaces exist + const spaces = await this.validateSpacesExist(dto.spaceUuids); + + // Validate no duplicate bookable configurations + await this.validateNoDuplicateBookableConfigs(dto.spaceUuids); + + // Create and save bookable spaces + return this.createBookableSpaces(spaces, dto.slots); + } + + /** + * Validate that all specified spaces exist + */ + private async validateSpacesExist( + spaceUuids: string[], + ): Promise { + const spaces = await this.spaceRepo.find({ + where: { uuid: In(spaceUuids) }, + }); + + if (spaces.length !== spaceUuids.length) { + const foundUuids = spaces.map((s) => s.uuid); + const missingUuids = spaceUuids.filter( + (uuid) => !foundUuids.includes(uuid), + ); + throw new NotFoundException( + `Spaces not found: ${missingUuids.join(', ')}`, + ); + } + + return spaces; + } + + /** + * Validate there are no existing bookable configurations for these spaces + */ + private async validateNoDuplicateBookableConfigs( + spaceUuids: string[], + ): Promise { + const existingBookables = await this.bookableRepo.find({ + where: { space: { uuid: In(spaceUuids) } }, + relations: ['space'], + }); + + if (existingBookables.length > 0) { + const existingUuids = [ + ...new Set(existingBookables.map((b) => b.space.uuid)), + ]; + throw new ConflictException( + `Bookable configuration already exists for spaces: ${existingUuids.join(', ')}`, + ); + } + } + + /** + * Validate time slots have valid format and logical times + */ + private validateTimeSlots(slots: BookingSlotDto[]): void { + slots.forEach((slot) => { + const start = timeToMinutes(slot.startTime); + const end = timeToMinutes(slot.endTime); + + if (start >= end) { + throw new BadRequestException( + `End time must be after start time for slot: ${slot.startTime}-${slot.endTime}`, + ); + } + }); + } + + /** + * Create bookable space entries after all validations pass + */ + private async createBookableSpaces( + spaces: SpaceEntity[], + slots: BookingSlotDto[], + ): Promise { + try { + const entries = spaces.flatMap((space) => + slots.map((slot) => + this.bookableRepo.create({ + space, + daysAvailable: slot.daysAvailable, + startTime: slot.startTime, + endTime: slot.endTime, + points: slot.points, + }), + ), + ); + + const data = await this.bookableRepo.save(entries); + return new SuccessResponseDto({ + data, + message: 'Successfully added new bookable spaces', + }); + } catch (error) { + if (error.code === '23505') { + throw new ConflictException( + 'Duplicate bookable space configuration detected', + ); + } + throw error; + } + } +} diff --git a/src/booking/services/index.ts b/src/booking/services/index.ts new file mode 100644 index 0000000..f8a6199 --- /dev/null +++ b/src/booking/services/index.ts @@ -0,0 +1 @@ +export * from './bookable-space.service';