task: add create booking API

This commit is contained in:
Mhd Zayd Skaff
2025-07-10 14:23:57 +03:00
parent a8d33bbc52
commit 48a6d15c96
19 changed files with 421 additions and 23 deletions

View File

@ -91,6 +91,20 @@ export class ControllerRoute {
'This endpoint allows you to update existing bookable spaces by providing the required details.';
};
};
static BOOKING = class {
public static readonly ROUTE = 'bookings';
static ACTIONS = class {
public static readonly ADD_BOOKING_SUMMARY = 'Add new booking';
public static readonly ADD_BOOKING_DESCRIPTION =
'This endpoint allows you to add new booking by providing the required details.';
public static readonly GET_ALL_BOOKINGS_SUMMARY = 'Get all bookings';
public static readonly GET_ALL_BOOKINGS_DESCRIPTION =
'This endpoint retrieves all bookings.';
};
};
static COMMUNITY = class {
public static readonly ROUTE = '/projects/:projectUuid/communities';
static ACTIONS = class {

View File

@ -14,6 +14,8 @@ import { createLogger } from 'winston';
import { winstonLoggerOptions } from '../logger/services/winston.logger';
import { AqiSpaceDailyPollutantStatsEntity } from '../modules/aqi/entities';
import { AutomationEntity } from '../modules/automation/entities';
import { BookableSpaceEntity } from '../modules/booking/entities/bookable-space.entity';
import { BookingEntity } from '../modules/booking/entities/booking.entity';
import { ClientEntity } from '../modules/client/entities';
import { CommunityEntity } from '../modules/community/entities';
import { DeviceStatusLogEntity } from '../modules/device-status-log/entities';
@ -58,7 +60,6 @@ import {
UserSpaceEntity,
} from '../modules/user/entities';
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
import { BookableSpaceEntity } from '../modules/booking/entities';
@Module({
imports: [
TypeOrmModule.forRootAsync({
@ -119,6 +120,7 @@ import { BookableSpaceEntity } from '../modules/booking/entities';
AqiSpaceDailyPollutantStatsEntity,
SpaceDailyOccupancyDurationEntity,
BookableSpaceEntity,
BookingEntity,
],
namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookableSpaceEntity } from './entities/bookable-space.entity';
import { BookingEntity } from './entities/booking.entity';
@Module({
providers: [],
exports: [],
controllers: [],
imports: [TypeOrmModule.forFeature([BookableSpaceEntity])],
imports: [TypeOrmModule.forFeature([BookableSpaceEntity, BookingEntity])],
})
export class BookableRepositoryModule {}
export class BookingRepositoryModule {}

View File

@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
UpdateDateColumn,
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { SpaceEntity } from '../../space/entities/space.entity';
import { UserEntity } from '../../user/entities';
@Entity('booking')
export class BookingEntity extends AbstractEntity {
@Column({
type: 'uuid',
default: () => 'gen_random_uuid()',
nullable: false,
})
public uuid: string;
@ManyToOne(() => SpaceEntity, (space) => space.bookableConfig)
space: SpaceEntity;
@ManyToOne(() => UserEntity, (user) => user.bookings)
user: UserEntity;
@Column({ type: Date, nullable: false })
date: Date;
@Column({ type: 'time' })
startTime: string;
@Column({ type: 'time' })
endTime: string;
@Column({ type: 'int', default: null })
cost?: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

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

View File

@ -0,0 +1,10 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { BookableSpaceEntity } from '../entities/bookable-space.entity';
@Injectable()
export class BookableSpaceEntityRepository extends Repository<BookableSpaceEntity> {
constructor(private dataSource: DataSource) {
super(BookableSpaceEntity, dataSource.createEntityManager());
}
}

View File

@ -1,10 +1,10 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { BookableSpaceEntity } from '../entities/bookable-space.entity';
import { DataSource, Repository } from 'typeorm';
import { BookingEntity } from '../entities/booking.entity';
@Injectable()
export class BookableSpaceEntityRepository extends Repository<BookableSpaceEntity> {
export class BookingEntityRepository extends Repository<BookingEntity> {
constructor(private dataSource: DataSource) {
super(BookableSpaceEntity, dataSource.createEntityManager());
super(BookingEntity, dataSource.createEntityManager());
}
}

View File

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

View File

@ -8,7 +8,7 @@ import {
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { BookableSpaceEntity } from '../../booking/entities';
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
import { CommunityEntity } from '../../community/entities';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
@ -20,6 +20,7 @@ import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -132,6 +133,9 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
@OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space)
bookableConfig: BookableSpaceEntity;
@OneToMany(() => BookingEntity, (booking) => booking.space)
bookings: BookingEntity[];
constructor(partial: Partial<SpaceEntity>) {
super();
Object.assign(this, partial);

View File

@ -29,6 +29,7 @@ import {
UserOtpDto,
UserSpaceDto,
} from '../dtos';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> {
@ -121,6 +122,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
)
deviceUserNotification: DeviceNotificationEntity[];
@OneToMany(() => BookingEntity, (booking) => booking.user)
bookings: BookingEntity[];
@ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true })
region: RegionEntity;
@ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, {

View File

@ -1,17 +1,28 @@
import { Global, Module } from '@nestjs/common';
import { BookableSpaceController } from './controllers';
import { BookableSpaceService } from './services';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
import { BookingRepositoryModule } from '@app/common/modules/booking/booking.repository.module';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceRepository } from '@app/common/modules/space';
import { UserRepository } from '@app/common/modules/user/repositories';
import { Global, Module } from '@nestjs/common';
import { BookableSpaceController } from './controllers/bookable-space.controller';
import { BookingController } from './controllers/booking.controller';
import { BookableSpaceService } from './services/bookable-space.service';
import { BookingService } from './services/booking.service';
@Global()
@Module({
controllers: [BookableSpaceController],
imports: [BookingRepositoryModule],
controllers: [BookableSpaceController, BookingController],
providers: [
BookableSpaceService,
BookingService,
BookableSpaceEntityRepository,
BookingEntityRepository,
SpaceRepository,
UserRepository,
],
exports: [BookableSpaceService],
exports: [BookableSpaceService, BookingService],
})
export class BookingModule {}

View File

@ -18,11 +18,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { plainToInstance } from 'class-transformer';
import { CreateBookableSpaceDto } from '../dtos';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { BookableSpaceResponseDto } from '../dtos/bookable-space-response.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
import { BookableSpaceService } from '../services';
import { BookableSpaceService } from '../services/bookable-space.service';
@ApiTags('Booking Module')
@Controller({

View File

@ -0,0 +1,76 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { CreateBookingDto } from '../dtos/create-booking.dto';
import { BookingService } from '../services/booking.service';
@ApiTags('Booking Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.BOOKING.ROUTE,
})
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_DESCRIPTION,
})
async create(
@Body() dto: CreateBookingDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const userUuid = req['user']?.uuid;
if (!userUuid) {
throw new Error('User UUID is required in the request');
}
const result = await this.bookingService.create(userUuid, dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully created booking',
});
}
// @ApiBearerAuth()
// @UseGuards(JwtAuthGuard)
// @Get()
// @ApiOperation({
// summary:
// ControllerRoute.BOOKABLE_SPACES.ACTIONS.GET_ALL_BOOKABLE_SPACES_SUMMARY,
// description:
// ControllerRoute.BOOKABLE_SPACES.ACTIONS
// .GET_ALL_BOOKABLE_SPACES_DESCRIPTION,
// })
// async findAll(
// @Query() query: BookableSpaceRequestDto,
// @Req() req: Request,
// ): Promise<PageResponse<BookableSpaceResponseDto>> {
// const project = req['user']?.project?.uuid;
// if (!project) {
// throw new Error('Project UUID is required in the request');
// }
// const { data, pagination } = await this.bookableSpaceService.findAll(
// query,
// project,
// );
// return new PageResponse<BookableSpaceResponseDto>(
// {
// data: data.map((space) =>
// plainToInstance(BookableSpaceResponseDto, space, {
// excludeExtraneousValues: true,
// }),
// ),
// message: 'Successfully fetched all bookable spaces',
// },
// pagination,
// );
// }
}

View File

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

View File

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDate, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator';
export class CreateBookingDto {
@ApiProperty({
type: 'string',
example: '4fa85f64-5717-4562-b3fc-2c963f66afa7',
})
@IsNotEmpty()
@IsUUID('4', { message: 'Invalid space UUID provided' })
spaceUuid: string;
@ApiProperty({
type: Date,
})
@IsNotEmpty()
@IsDate()
date: Date;
@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;
}

View File

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

View File

@ -2,7 +2,7 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PageResponseDto } from '@app/common/dto/pagination.response.dto';
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { TypeORMCustomModel } from '@app/common/models/typeOrmCustom.model';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import {
@ -12,8 +12,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { In } from 'typeorm';
import { CreateBookableSpaceDto } from '../dtos';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
@Injectable()

View File

@ -0,0 +1,202 @@
import { DaysEnum } from '@app/common/constants/days.enum';
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import { UserRepository } from '@app/common/modules/user/repositories/user.repository';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateBookingDto } from '../dtos/create-booking.dto';
@Injectable()
export class BookingService {
constructor(
private readonly bookingEntityRepository: BookingEntityRepository,
private readonly spaceRepository: SpaceRepository,
private readonly userRepository: UserRepository,
) {}
async create(userUuid: string, dto: CreateBookingDto) {
console.log(userUuid);
const user = await this.userRepository.findOne({
where: { uuid: userUuid },
relations: ['userSpaces', 'userSpaces.space'],
});
console.log(user.userSpaces);
if (!user.userSpaces.some(({ space }) => space.uuid === dto.spaceUuid)) {
throw new ForbiddenException(
`User does not have permission to book this space: ${dto.spaceUuid}`,
);
}
// Validate time slots first
this.validateTimeSlot(dto.startTime, dto.endTime);
// fetch spaces exist
const space = await this.getSpaceConfigurationAndBookings(dto.spaceUuid);
// Validate booking availability
this.validateBookingAvailability(space, dto);
// Create and save booking
return this.createBookings(space, userUuid, dto);
}
// async findAll(
// { active, page, size, configured, search }: BookingRequestDto,
// project: string,
// ): Promise<{
// data: BaseResponseDto['data'];
// pagination: PageResponseDto;
// }> {
// let qb = this.spaceRepository
// .createQueryBuilder('space')
// .leftJoinAndSelect('space.parent', 'parentSpace')
// .leftJoinAndSelect('space.community', 'community')
// .where('community.project = :project', { project });
// if (search) {
// qb = qb.andWhere(
// 'space.spaceName ILIKE :search OR community.name ILIKE :search OR parentSpace.spaceName ILIKE :search',
// { search: `%${search}%` },
// );
// }
// if (configured) {
// qb = qb
// .leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
// .andWhere('bookableConfig.uuid IS NOT NULL');
// if (active !== undefined) {
// qb = qb.andWhere('bookableConfig.active = :active', { active });
// }
// } else {
// qb = qb
// .leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
// .andWhere('bookableConfig.uuid IS NULL');
// }
// const customModel = TypeORMCustomModel(this.spaceRepository);
// const { baseResponseDto, paginationResponseDto } =
// await customModel.findAll({ page, size, modelName: 'space' }, qb);
// return {
// data: baseResponseDto.data.map((space) => {
// return {
// ...space,
// virtualLocation: `${space.community?.name} - ${space.parent ? space.parent?.spaceName + ' - ' : ''}${space.spaceName}`,
// };
// }),
// pagination: paginationResponseDto,
// };
// }
/**
* Fetch space by UUID and throw an error if not found or if not configured for booking
*/
private async getSpaceConfigurationAndBookings(
spaceUuid: string,
): Promise<SpaceEntity> {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: ['bookableConfig', 'bookings'],
});
if (!space) {
throw new NotFoundException(`Space not found: ${spaceUuid}`);
}
if (!space.bookableConfig) {
throw new NotFoundException(
`This space is not configured for booking: ${spaceUuid}`,
);
}
return space;
}
/**
* Ensure the slot start time is before the end time
*/
private validateTimeSlot(startTime: string, endTime: string): void {
const start = timeToMinutes(startTime);
const end = timeToMinutes(endTime);
if (start >= end) {
throw new BadRequestException(
`End time must be after start time for slot: ${startTime}-${endTime}`,
);
}
}
/**
* check if the space is available for booking on the requested day
* and if the requested time slot is within the available hours
*/
private validateBookingAvailability(
space: SpaceEntity,
dto: CreateBookingDto,
): void {
// Check if the space is available for booking on the requested day
const availableDays = space.bookableConfig?.daysAvailable || [];
const requestedDay = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'short',
}) as DaysEnum;
if (!availableDays.includes(requestedDay)) {
const dayFullName = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'long',
});
throw new BadRequestException(
`Space is not available for booking on ${dayFullName}s`,
);
}
const dtoStartTimeInMinutes = timeToMinutes(dto.startTime);
const dtoEndTimeInMinutes = timeToMinutes(dto.endTime);
if (
dtoStartTimeInMinutes < timeToMinutes(space.bookableConfig.startTime) ||
dtoEndTimeInMinutes > timeToMinutes(space.bookableConfig.endTime)
) {
throw new BadRequestException(
`Booking time must be within the available hours for space: ${space.spaceName}`,
);
}
const previousBookings = space.bookings.filter(
(booking) =>
timeToMinutes(booking.startTime) < dtoEndTimeInMinutes &&
timeToMinutes(booking.endTime) > dtoStartTimeInMinutes,
);
if (previousBookings.length > 0) {
// tell the user what time is unavailable
const unavailableTimes = previousBookings.map((booking) => {
return `${booking.startTime}-${booking.endTime}`;
});
throw new ConflictException(
`Space is already booked during this times: ${unavailableTimes.join(', ')}`,
);
}
}
/**
* Create bookable space entries after all validations pass
*/
private async createBookings(
space: SpaceEntity,
user: string,
{ spaceUuid, date, ...dto }: CreateBookingDto,
) {
const entry = this.bookingEntityRepository.create({
space: { uuid: spaceUuid },
user: { uuid: user },
...dto,
date: new Date(date),
cost: space.bookableConfig?.points || null,
});
return this.bookingEntityRepository.save(entry);
}
}

View File

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