feat: implement Booking module with BookableSpace entity, controller, service, and DTOs for managing bookable spaces

This commit is contained in:
faris Aljohari
2025-06-17 22:02:13 -06:00
parent 8d44b66dd3
commit 332b2f5851
11 changed files with 276 additions and 0 deletions

View File

@ -69,7 +69,16 @@ export class ControllerRoute {
'Retrieve the list of all regions registered in Syncrow.'; '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 { static COMMUNITY = class {
public static readonly ROUTE = '/projects/:projectUuid/communities'; public static readonly ROUTE = '/projects/:projectUuid/communities';
static ACTIONS = class { static ACTIONS = class {

View File

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

View File

@ -39,6 +39,7 @@ import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston
import { AqiModule } from './aqi/aqi.module'; import { AqiModule } from './aqi/aqi.module';
import { OccupancyModule } from './occupancy/occupancy.module'; import { OccupancyModule } from './occupancy/occupancy.module';
import { WeatherModule } from './weather/weather.module'; import { WeatherModule } from './weather/weather.module';
import { BookingModule } from './booking';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
@ -82,6 +83,7 @@ import { WeatherModule } from './weather/weather.module';
OccupancyModule, OccupancyModule,
WeatherModule, WeatherModule,
AqiModule, AqiModule,
BookingModule,
], ],
providers: [ providers: [
{ {

View File

@ -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 {}

View File

@ -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<BaseResponseDto> {
return this.bookableSpaceService.create(dto);
}
}

View File

@ -0,0 +1 @@
export * from './bookable-space.controller';

View File

@ -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[];
}

View File

@ -0,0 +1 @@
export * from './create-bookable-space.dto';

1
src/booking/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './booking.module';

View File

@ -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<BookableSpaceEntity>,
@InjectRepository(SpaceEntity)
private spaceRepo: Repository<SpaceEntity>,
) {}
async create(dto: CreateBookableSpaceDto): Promise<BaseResponseDto> {
// 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<SpaceEntity[]> {
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<void> {
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<BaseResponseDto> {
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;
}
}
}

View File

@ -0,0 +1 @@
export * from './bookable-space.service';