Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import { NpsModule } from './nps/nps.module';
import { DoorAccessModule } from './integrations/access-control/door-access.module';

Expand Down Expand Up @@ -150,6 +151,7 @@ import { DoorAccessModule } from './integrations/access-control/door-access.modu
LeadsModule,
CreditsModule,
TeamsModule,
ResourcesModule,
NpsModule,
DoorAccessModule,
],
Expand Down
7 changes: 7 additions & 0 deletions backend/src/resources/dto/book-resource.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions backend/src/resources/dto/create-resource.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 54 additions & 0 deletions backend/src/resources/entities/resource-booking.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions backend/src/resources/entities/resource.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions backend/src/resources/enums/resource-type.enum.ts
Original file line number Diff line number Diff line change
@@ -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',
}
64 changes: 64 additions & 0 deletions backend/src/resources/resources.controller.ts
Original file line number Diff line number Diff line change
@@ -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<CreateResourceDto>) {
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) };
}
}
14 changes: 14 additions & 0 deletions backend/src/resources/resources.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
91 changes: 91 additions & 0 deletions backend/src/resources/resources.service.ts
Original file line number Diff line number Diff line change
@@ -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<Resource>,
@InjectRepository(ResourceBooking) private bookingRepo: Repository<ResourceBooking>,
) {}

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<CreateResourceDto>) {
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' },
});
}
}
Loading