mirror of
https://github.com/SyncrowIOT/backend.git
synced 2025-07-10 15:17:41 +00:00
Merge pull request #410 from SyncrowIOT/SP-1754-be-implement-configure-space
SP-1754-be-implement-configure-space
This commit is contained in:
@ -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 {
|
||||||
|
@ -58,6 +58,7 @@ import {
|
|||||||
UserSpaceEntity,
|
UserSpaceEntity,
|
||||||
} from '../modules/user/entities';
|
} from '../modules/user/entities';
|
||||||
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
|
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
|
||||||
|
import { BookableSpaceEntity } from '../modules/booking/entities';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
@ -117,6 +118,7 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
|
|||||||
PresenceSensorDailySpaceEntity,
|
PresenceSensorDailySpaceEntity,
|
||||||
AqiSpaceDailyPollutantStatsEntity,
|
AqiSpaceDailyPollutantStatsEntity,
|
||||||
SpaceDailyOccupancyDurationEntity,
|
SpaceDailyOccupancyDurationEntity,
|
||||||
|
BookableSpaceEntity,
|
||||||
],
|
],
|
||||||
namingStrategy: new SnakeNamingStrategy(),
|
namingStrategy: new SnakeNamingStrategy(),
|
||||||
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),
|
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),
|
||||||
|
5
libs/common/src/helper/timeToMinutes.ts
Normal file
5
libs/common/src/helper/timeToMinutes.ts
Normal 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;
|
||||||
|
}
|
11
libs/common/src/modules/booking/booking.repository.module.ts
Normal file
11
libs/common/src/modules/booking/booking.repository.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BookableSpaceEntity } from './entities/bookable-space.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [],
|
||||||
|
exports: [],
|
||||||
|
controllers: [],
|
||||||
|
imports: [TypeOrmModule.forFeature([BookableSpaceEntity])],
|
||||||
|
})
|
||||||
|
export class BookableRepositoryModule {}
|
@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SpaceEntity } from '../../space/entities/space.entity';
|
||||||
|
import { DaysEnum } from '@app/common/constants/days.enum';
|
||||||
|
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
||||||
|
|
||||||
|
@Entity('bookable-space')
|
||||||
|
export class BookableSpaceEntity extends AbstractEntity {
|
||||||
|
@Column({
|
||||||
|
type: 'uuid',
|
||||||
|
default: () => 'gen_random_uuid()',
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
public uuid: string;
|
||||||
|
|
||||||
|
@OneToOne(() => SpaceEntity, (space) => space.bookableConfig)
|
||||||
|
@JoinColumn({ name: 'space_uuid' })
|
||||||
|
space: SpaceEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DaysEnum,
|
||||||
|
array: true,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
daysAvailable: DaysEnum[];
|
||||||
|
|
||||||
|
@Column({ type: 'time' })
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@Column({ type: 'time' })
|
||||||
|
endTime: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
points: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
1
libs/common/src/modules/booking/entities/index.ts
Normal file
1
libs/common/src/modules/booking/entities/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './bookable-space.entity';
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
1
libs/common/src/modules/booking/repositories/index.ts
Normal file
1
libs/common/src/modules/booking/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './booking.repository';
|
@ -1,4 +1,12 @@
|
|||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SpaceDto } from '../dtos';
|
||||||
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
||||||
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
|
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
|
||||||
import { CommunityEntity } from '../../community/entities';
|
import { CommunityEntity } from '../../community/entities';
|
||||||
@ -9,9 +17,9 @@ import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
|
|||||||
import { SceneEntity } from '../../scene/entities';
|
import { SceneEntity } from '../../scene/entities';
|
||||||
import { SpaceModelEntity } from '../../space-model';
|
import { SpaceModelEntity } from '../../space-model';
|
||||||
import { UserSpaceEntity } from '../../user/entities';
|
import { UserSpaceEntity } from '../../user/entities';
|
||||||
import { SpaceDto } from '../dtos';
|
|
||||||
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
|
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
|
||||||
import { SubspaceEntity } from './subspace/subspace.entity';
|
import { SubspaceEntity } from './subspace/subspace.entity';
|
||||||
|
import { BookableSpaceEntity } from '../../booking/entities';
|
||||||
|
|
||||||
@Entity({ name: 'space' })
|
@Entity({ name: 'space' })
|
||||||
export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
||||||
@ -115,6 +123,9 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
|||||||
)
|
)
|
||||||
occupancyDaily: SpaceDailyOccupancyDurationEntity[];
|
occupancyDaily: SpaceDailyOccupancyDurationEntity[];
|
||||||
|
|
||||||
|
@OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space)
|
||||||
|
bookableConfig: BookableSpaceEntity;
|
||||||
|
|
||||||
constructor(partial: Partial<SpaceEntity>) {
|
constructor(partial: Partial<SpaceEntity>) {
|
||||||
super();
|
super();
|
||||||
Object.assign(this, partial);
|
Object.assign(this, partial);
|
||||||
|
@ -44,6 +44,7 @@ import { OccupancyModule } from './occupancy/occupancy.module';
|
|||||||
import { WeatherModule } from './weather/weather.module';
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
|
||||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||||
|
import { BookingModule } from './booking';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@ -98,6 +99,7 @@ import { SchedulerModule } from './scheduler/scheduler.module';
|
|||||||
AqiModule,
|
AqiModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
NestScheduleModule.forRoot(),
|
NestScheduleModule.forRoot(),
|
||||||
|
BookingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
17
src/booking/booking.module.ts
Normal file
17
src/booking/booking.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { BookableSpaceController } from './controllers';
|
||||||
|
import { BookableSpaceService } from './services';
|
||||||
|
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
|
||||||
|
import { SpaceRepository } from '@app/common/modules/space';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [BookableSpaceController],
|
||||||
|
providers: [
|
||||||
|
BookableSpaceService,
|
||||||
|
BookableSpaceEntityRepository,
|
||||||
|
SpaceRepository,
|
||||||
|
],
|
||||||
|
exports: [BookableSpaceService],
|
||||||
|
})
|
||||||
|
export class BookingModule {}
|
30
src/booking/controllers/bookable-space.controller.ts
Normal file
30
src/booking/controllers/bookable-space.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
1
src/booking/controllers/index.ts
Normal file
1
src/booking/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './bookable-space.controller';
|
62
src/booking/dtos/create-bookable-space.dto.ts
Normal file
62
src/booking/dtos/create-bookable-space.dto.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// dtos/bookable-space.dto.ts
|
||||||
|
import { DaysEnum } from '@app/common/constants/days.enum';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsInt,
|
||||||
|
ArrayMinSize,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
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({
|
||||||
|
enum: DaysEnum,
|
||||||
|
isArray: true,
|
||||||
|
example: [DaysEnum.MON, DaysEnum.WED, DaysEnum.FRI],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1, { message: 'At least one day must be selected' })
|
||||||
|
@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 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0, { message: 'Points cannot be negative' })
|
||||||
|
@Max(1000, { message: 'Points cannot exceed 1000' })
|
||||||
|
points: number;
|
||||||
|
}
|
1
src/booking/dtos/index.ts
Normal file
1
src/booking/dtos/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './create-bookable-space.dto';
|
1
src/booking/index.ts
Normal file
1
src/booking/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './booking.module';
|
127
src/booking/services/bookable-space.service.ts
Normal file
127
src/booking/services/bookable-space.service.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CreateBookableSpaceDto } from '../dtos';
|
||||||
|
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
|
||||||
|
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
|
||||||
|
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
|
||||||
|
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
|
||||||
|
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookableSpaceService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository,
|
||||||
|
private readonly spaceRepository: SpaceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateBookableSpaceDto): Promise<BaseResponseDto> {
|
||||||
|
// Validate time slots first
|
||||||
|
this.validateTimeSlot(dto.startTime, dto.endTime);
|
||||||
|
|
||||||
|
// fetch spaces exist
|
||||||
|
const spaces = await this.getSpacesOrFindMissing(dto.spaceUuids);
|
||||||
|
|
||||||
|
// Validate no duplicate bookable configurations
|
||||||
|
await this.validateNoDuplicateBookableConfigs(dto.spaceUuids);
|
||||||
|
|
||||||
|
// Create and save bookable spaces
|
||||||
|
return this.createBookableSpaces(spaces, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch spaces by UUIDs and throw an error if any are missing
|
||||||
|
*/
|
||||||
|
private async getSpacesOrFindMissing(
|
||||||
|
spaceUuids: string[],
|
||||||
|
): Promise<SpaceEntity[]> {
|
||||||
|
const spaces = await this.spaceRepository.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.bookableSpaceEntityRepository.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(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create bookable space entries after all validations pass
|
||||||
|
*/
|
||||||
|
private async createBookableSpaces(
|
||||||
|
spaces: SpaceEntity[],
|
||||||
|
dto: CreateBookableSpaceDto,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
try {
|
||||||
|
const entries = spaces.map((space) =>
|
||||||
|
this.bookableSpaceEntityRepository.create({
|
||||||
|
space,
|
||||||
|
daysAvailable: dto.daysAvailable,
|
||||||
|
startTime: dto.startTime,
|
||||||
|
endTime: dto.endTime,
|
||||||
|
points: dto.points,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await this.bookableSpaceEntityRepository.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
src/booking/services/index.ts
Normal file
1
src/booking/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './bookable-space.service';
|
Reference in New Issue
Block a user