From 8db98aa5d33f67978fcc4c08c75e828835f0154f Mon Sep 17 00:00:00 2001 From: nanaf6203-bit Date: Mon, 29 Jun 2026 09:47:13 +0000 Subject: [PATCH] feat(resources): add resource and equipment booking module --- backend/src/app.module.ts | 2 + .../src/resources/dto/book-resource.dto.ts | 7 ++ .../src/resources/dto/create-resource.dto.ts | 12 +++ .../entities/resource-booking.entity.ts | 54 +++++++++++ .../src/resources/entities/resource.entity.ts | 38 ++++++++ .../src/resources/enums/resource-type.enum.ts | 10 ++ backend/src/resources/resources.controller.ts | 64 +++++++++++++ backend/src/resources/resources.module.ts | 14 +++ backend/src/resources/resources.service.ts | 91 +++++++++++++++++++ 9 files changed, 292 insertions(+) create mode 100644 backend/src/resources/dto/book-resource.dto.ts create mode 100644 backend/src/resources/dto/create-resource.dto.ts create mode 100644 backend/src/resources/entities/resource-booking.entity.ts create mode 100644 backend/src/resources/entities/resource.entity.ts create mode 100644 backend/src/resources/enums/resource-type.enum.ts create mode 100644 backend/src/resources/resources.controller.ts create mode 100644 backend/src/resources/resources.module.ts create mode 100644 backend/src/resources/resources.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 57c4631..b94b89b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -46,6 +46,7 @@ import { MaintenanceModule } from './maintenance/maintenance.module'; import { LeadsModule } from './leads/leads.module'; import { CreditsModule } from './credits/credits.module'; import { TeamsModule } from './teams/teams.module'; +import { ResourcesModule } from './resources/resources.module'; @Module({ imports: [ @@ -148,6 +149,7 @@ import { TeamsModule } from './teams/teams.module'; LeadsModule, CreditsModule, TeamsModule, + ResourcesModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/resources/dto/book-resource.dto.ts b/backend/src/resources/dto/book-resource.dto.ts new file mode 100644 index 0000000..2736906 --- /dev/null +++ b/backend/src/resources/dto/book-resource.dto.ts @@ -0,0 +1,7 @@ +import { IsDateString, IsInt, IsOptional, Min } from 'class-validator'; + +export class BookResourceDto { + @IsDateString() startTime: string; + @IsDateString() endTime: string; + @IsInt() @Min(1) @IsOptional() quantityRequested?: number; +} diff --git a/backend/src/resources/dto/create-resource.dto.ts b/backend/src/resources/dto/create-resource.dto.ts new file mode 100644 index 0000000..e610bd3 --- /dev/null +++ b/backend/src/resources/dto/create-resource.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; +import { ResourceType } from '../enums/resource-type.enum'; + +export class CreateResourceDto { + @IsString() @IsNotEmpty() name: string; + @IsString() @IsOptional() description?: string; + @IsEnum(ResourceType) type: ResourceType; + @IsInt() @Min(1) totalQuantity: number; + @IsInt() @Min(0) @IsOptional() pricePerHour?: number; + @IsOptional() images?: string[]; + @IsString() @IsOptional() locationId?: string; +} diff --git a/backend/src/resources/entities/resource-booking.entity.ts b/backend/src/resources/entities/resource-booking.entity.ts new file mode 100644 index 0000000..3f4c2ad --- /dev/null +++ b/backend/src/resources/entities/resource-booking.entity.ts @@ -0,0 +1,54 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Resource } from './resource.entity'; +import { User } from '../../../users/entities/user.entity'; + +export enum ResourceBookingStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + CANCELLED = 'cancelled', + COMPLETED = 'completed', +} + +@Entity('resource_bookings') +export class ResourceBooking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + resourceId: string; + + @ManyToOne(() => Resource, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'resourceId' }) + resource: Resource; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'timestamptz' }) + startTime: Date; + + @Column({ type: 'timestamptz' }) + endTime: Date; + + @Column({ type: 'int', default: 1 }) + quantityRequested: number; + + @Column({ type: 'enum', enum: ResourceBookingStatus, default: ResourceBookingStatus.PENDING }) + status: ResourceBookingStatus; + + @Column({ type: 'uuid', nullable: true }) + paymentId: string; + + @Column({ type: 'int', default: 0 }) + totalAmount: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/resources/entities/resource.entity.ts b/backend/src/resources/entities/resource.entity.ts new file mode 100644 index 0000000..b4e4330 --- /dev/null +++ b/backend/src/resources/entities/resource.entity.ts @@ -0,0 +1,38 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { ResourceType } from '../enums/resource-type.enum'; + +@Entity('resources') +export class Resource { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'enum', enum: ResourceType }) + type: ResourceType; + + @Column({ type: 'int', default: 1 }) + totalQuantity: number; + + @Column({ type: 'int', default: 0 }) + pricePerHour: number; + + @Column({ default: true }) + isAvailable: boolean; + + @Column({ type: 'jsonb', nullable: true }) + images: string[]; + + @Column({ type: 'uuid', nullable: true }) + locationId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/resources/enums/resource-type.enum.ts b/backend/src/resources/enums/resource-type.enum.ts new file mode 100644 index 0000000..622db7b --- /dev/null +++ b/backend/src/resources/enums/resource-type.enum.ts @@ -0,0 +1,10 @@ +export enum ResourceType { + PROJECTOR = 'projector', + MONITOR = 'monitor', + WHITEBOARD = 'whiteboard', + PODCAST_BOOTH = 'podcast_booth', + CAMERA = 'camera', + STANDING_DESK = 'standing_desk', + LOCKER = 'locker', + OTHER = 'other', +} diff --git a/backend/src/resources/resources.controller.ts b/backend/src/resources/resources.controller.ts new file mode 100644 index 0000000..065f698 --- /dev/null +++ b/backend/src/resources/resources.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ResourcesService } from './resources.service'; +import { CreateResourceDto } from './dto/create-resource.dto'; +import { BookResourceDto } from './dto/book-resource.dto'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Resources') +@ApiBearerAuth() +@Controller('resources') +export class ResourcesController { + constructor(private readonly service: ResourcesService) {} + + @Get() + async findAll() { + return { data: await this.service.findAll() }; + } + + @Get('bookings/my') + async getMyBookings(@GetCurrentUser('id') userId: string) { + return { data: await this.service.getMyBookings(userId) }; + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return { data: await this.service.findOne(id) }; + } + + @Get(':id/availability') + async checkAvailability(@Param('id', ParseUUIDPipe) id: string, @Query('date') date?: string) { + return this.service.checkAvailability(id, date); + } + + @Post() + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async create(@Body() dto: CreateResourceDto) { + return { message: 'Resource created', data: await this.service.create(dto) }; + } + + @Patch(':id') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial) { + return { data: await this.service.update(id, dto) }; + } + + @Delete(':id') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.service.remove(id); + return { message: 'Resource removed' }; + } + + @Post(':id/book') + async book(@Param('id', ParseUUIDPipe) id: string, @GetCurrentUser('id') userId: string, @Body() dto: BookResourceDto) { + return { data: await this.service.book(id, userId, dto) }; + } +} diff --git a/backend/src/resources/resources.module.ts b/backend/src/resources/resources.module.ts new file mode 100644 index 0000000..c70864d --- /dev/null +++ b/backend/src/resources/resources.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Resource } from './entities/resource.entity'; +import { ResourceBooking } from './entities/resource-booking.entity'; +import { ResourcesService } from './resources.service'; +import { ResourcesController } from './resources.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Resource, ResourceBooking])], + controllers: [ResourcesController], + providers: [ResourcesService], + exports: [ResourcesService], +}) +export class ResourcesModule {} diff --git a/backend/src/resources/resources.service.ts b/backend/src/resources/resources.service.ts new file mode 100644 index 0000000..7be8771 --- /dev/null +++ b/backend/src/resources/resources.service.ts @@ -0,0 +1,91 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan, LessThan } from 'typeorm'; +import { Resource } from './entities/resource.entity'; +import { ResourceBooking, ResourceBookingStatus } from './entities/resource-booking.entity'; +import { CreateResourceDto } from './dto/create-resource.dto'; +import { BookResourceDto } from './dto/book-resource.dto'; + +@Injectable() +export class ResourcesService { + constructor( + @InjectRepository(Resource) private resourceRepo: Repository, + @InjectRepository(ResourceBooking) private bookingRepo: Repository, + ) {} + + async findAll() { + return this.resourceRepo.find({ where: { isAvailable: true }, order: { createdAt: 'DESC' } }); + } + + async findOne(id: string) { + const r = await this.resourceRepo.findOne({ where: { id } }); + if (!r) throw new NotFoundException('Resource not found'); + return r; + } + + async create(dto: CreateResourceDto) { + return this.resourceRepo.save(this.resourceRepo.create(dto)); + } + + async update(id: string, dto: Partial) { + await this.findOne(id); + await this.resourceRepo.update(id, dto); + return this.findOne(id); + } + + async remove(id: string) { + await this.findOne(id); + await this.resourceRepo.update(id, { isAvailable: false }); + } + + async checkAvailability(id: string, date?: string) { + const resource = await this.findOne(id); + if (!date) return { available: resource.isAvailable, totalQuantity: resource.totalQuantity }; + + const start = new Date(date); + const end = new Date(date); + end.setHours(23, 59, 59); + + const bookedQty = await this.bookingRepo + .createQueryBuilder('rb') + .select('SUM(rb.quantityRequested)', 'total') + .where('rb.resourceId = :id', { id }) + .andWhere('rb.status IN (:...statuses)', { statuses: [ResourceBookingStatus.CONFIRMED, ResourceBookingStatus.PENDING] }) + .andWhere('rb.startTime <= :end', { end }) + .andWhere('rb.endTime >= :start', { start }) + .getRawOne(); + + const booked = Number(bookedQty?.total ?? 0); + return { available: booked < resource.totalQuantity, availableQuantity: resource.totalQuantity - booked, totalQuantity: resource.totalQuantity }; + } + + async book(resourceId: string, userId: string, dto: BookResourceDto) { + const resource = await this.findOne(resourceId); + const qty = dto.quantityRequested ?? 1; + + const availability = await this.checkAvailability(resourceId, dto.startTime); + if (!availability.available || (availability.availableQuantity ?? 0) < qty) { + throw new BadRequestException('Resource not available for the requested quantity and time'); + } + + const hours = (new Date(dto.endTime).getTime() - new Date(dto.startTime).getTime()) / (1000 * 60 * 60); + const totalAmount = Math.round(resource.pricePerHour * hours * qty); + + return this.bookingRepo.save(this.bookingRepo.create({ + resourceId, userId, + startTime: new Date(dto.startTime), + endTime: new Date(dto.endTime), + quantityRequested: qty, + totalAmount, + status: ResourceBookingStatus.CONFIRMED, + })); + } + + async getMyBookings(userId: string) { + return this.bookingRepo.find({ + where: { userId }, + relations: ['resource'], + order: { createdAt: 'DESC' }, + }); + } +}