diff --git a/src/app.module.ts b/src/app.module.ts index 42a7c19..4e08f98 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module'; import { CustomersModule } from './customers/customers.module'; import configuration from './config/configuration'; import { getTypeOrmOptions } from './database/typeorm-options'; +import { ReservesModule } from './reserves/reserves.module'; import { SalonsModule } from './salons/salons.module'; import { ServicesModule } from './services/services.module'; import { StylistsModule } from './stylists/stylists.module'; @@ -23,6 +24,7 @@ import { UsersModule } from './users/users.module'; StylistsModule, ServicesModule, CustomersModule, + ReservesModule, ], controllers: [], providers: [], diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index c0a3f9c..0021562 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -4,6 +4,7 @@ import { AuthorizationService } from './authorization.service'; import { Permission } from './entities/permission.entity'; import { UserPermission } from './entities/user-permission.entity'; import { CustomerPolicy } from './policies/customer.policy'; +import { ReservePolicy } from './policies/reserve.policy'; import { SalonPolicy } from './policies/salon.policy'; import { ServicePolicy } from './policies/service.policy'; import { StylistPolicy } from './policies/stylist.policy'; @@ -16,6 +17,7 @@ import { StylistPolicy } from './policies/stylist.policy'; ServicePolicy, CustomerPolicy, StylistPolicy, + ReservePolicy, ], exports: [ AuthorizationService, @@ -23,6 +25,7 @@ import { StylistPolicy } from './policies/stylist.policy'; ServicePolicy, CustomerPolicy, StylistPolicy, + ReservePolicy, ], }) export class AuthorizationModule {} diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts index 648d05b..eb6b40c 100644 --- a/src/authorization/enums/permission-code.enum.ts +++ b/src/authorization/enums/permission-code.enum.ts @@ -13,4 +13,6 @@ export enum PermissionCode { SERVICES_READ = 'services:read', SERVICES_WRITE = 'services:write', SERVICES_DELETE = 'services:delete', + RESERVES_READ = 'reserves:read', + RESERVES_WRITE = 'reserves:write', } diff --git a/src/authorization/policies/reserve.policy.ts b/src/authorization/policies/reserve.policy.ts new file mode 100644 index 0000000..c0687f7 --- /dev/null +++ b/src/authorization/policies/reserve.policy.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { Reserve } from '../../reserves/entities/reserve.entity'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class ReservePolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canCreate(user: AuthUser, reserve: Pick): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return reserve.salon.ownerId === user.id; + } + if (user.role === UserRole.CUSTOMER) { + return reserve.customer.userId === user.id; + } + return false; + } + + canRead(user: AuthUser, reserve: Reserve): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return reserve.salon.ownerId === user.id; + } + if (user.role === UserRole.CUSTOMER) { + return reserve.customer.userId === user.id; + } + return false; + } + + canUpdate(user: AuthUser, reserve: Reserve): boolean { + return this.canRead(user, reserve); + } +} diff --git a/src/database/migrations/1719900000000-AddReserves.ts b/src/database/migrations/1719900000000-AddReserves.ts new file mode 100644 index 0000000..a3a024c --- /dev/null +++ b/src/database/migrations/1719900000000-AddReserves.ts @@ -0,0 +1,183 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddReserves1719900000000 implements MigrationInterface { + name = 'AddReserves1719900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "reserves_status_enum" AS ENUM( + 'pending', + 'confirmed', + 'completed', + 'cancelled', + 'no_show' + ) + `); + + await queryRunner.createTable( + new Table({ + name: 'reserves', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'customerId', type: 'uuid' }, + { name: 'stylistId', type: 'uuid' }, + { name: 'appointmentDate', type: 'date' }, + { name: 'startTime', type: 'time' }, + { name: 'endTime', type: 'time' }, + { + name: 'status', + type: 'reserves_status_enum', + default: `'pending'`, + }, + { name: 'totalAmount', type: 'decimal', precision: 12, scale: 2 }, + { name: 'depositAmount', type: 'decimal', precision: 12, scale: 2 }, + { name: 'remainingAmount', type: 'decimal', precision: 12, scale: 2 }, + { name: 'cancellationReason', type: 'text', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'reserve_items', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'reserveId', type: 'uuid' }, + { name: 'serviceId', type: 'uuid' }, + { name: 'serviceSkillId', type: 'uuid' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'reserves', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'reserves', + new TableForeignKey({ + columnNames: ['customerId'], + referencedTableName: 'customers', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'reserves', + new TableForeignKey({ + columnNames: ['stylistId'], + referencedTableName: 'stylists', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'reserve_items', + new TableForeignKey({ + columnNames: ['reserveId'], + referencedTableName: 'reserves', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'reserve_items', + new TableForeignKey({ + columnNames: ['serviceId'], + referencedTableName: 'services', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await queryRunner.createForeignKey( + 'reserve_items', + new TableForeignKey({ + columnNames: ['serviceSkillId'], + referencedTableName: 'service_skills', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await queryRunner.createIndex( + 'reserves', + new TableIndex({ + name: 'IDX_reserves_salonId_appointmentDate', + columnNames: ['salonId', 'appointmentDate'], + }), + ); + + await queryRunner.createIndex( + 'reserves', + new TableIndex({ + name: 'IDX_reserves_stylistId_appointmentDate', + columnNames: ['stylistId', 'appointmentDate'], + }), + ); + + await queryRunner.createIndex( + 'reserves', + new TableIndex({ + name: 'IDX_reserves_customerId', + columnNames: ['customerId'], + }), + ); + + await queryRunner.createIndex( + 'reserves', + new TableIndex({ + name: 'IDX_reserves_status', + columnNames: ['status'], + }), + ); + + await queryRunner.createIndex( + 'reserve_items', + new TableIndex({ + name: 'IDX_reserve_items_reserveId', + columnNames: ['reserveId'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('reserve_items', true); + await queryRunner.dropTable('reserves', true); + await queryRunner.query(`DROP TYPE "reserves_status_enum"`); + } +} diff --git a/src/reserves/dto/create-reserve-item.dto.ts b/src/reserves/dto/create-reserve-item.dto.ts new file mode 100644 index 0000000..643c769 --- /dev/null +++ b/src/reserves/dto/create-reserve-item.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class CreateReserveItemDto { + @IsUUID() + @IsNotEmpty() + serviceId: string; + + @IsUUID() + @IsNotEmpty() + serviceSkillId: string; +} diff --git a/src/reserves/dto/create-reserve.dto.ts b/src/reserves/dto/create-reserve.dto.ts new file mode 100644 index 0000000..9429f9e --- /dev/null +++ b/src/reserves/dto/create-reserve.dto.ts @@ -0,0 +1,50 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNotEmpty, + IsNumber, + IsUUID, + Matches, + Min, + ValidateNested, +} from 'class-validator'; +import { CreateReserveItemDto } from './create-reserve-item.dto'; + +export class CreateReserveDto { + @IsUUID() + @IsNotEmpty() + salonId: string; + + @IsUUID() + @IsNotEmpty() + customerId: string; + + @IsUUID() + @IsNotEmpty() + stylistId: string; + + @IsDateString() + appointmentDate: string; + + @Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/) + startTime: string; + + @Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/) + endTime: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + totalAmount: number; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + depositAmount: number; + + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateReserveItemDto) + items: CreateReserveItemDto[]; +} diff --git a/src/reserves/dto/find-reserves-query.dto.ts b/src/reserves/dto/find-reserves-query.dto.ts new file mode 100644 index 0000000..0025aa2 --- /dev/null +++ b/src/reserves/dto/find-reserves-query.dto.ts @@ -0,0 +1,24 @@ +import { IsDateString, IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { ReserveStatus } from '../enums/reserve-status.enum'; + +export class FindReservesQueryDto { + @IsOptional() + @IsUUID() + salonId?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + stylistId?: string; + + @IsOptional() + @IsEnum(ReserveStatus) + status?: ReserveStatus; + + @IsOptional() + @IsDateString() + appointmentDate?: string; +} diff --git a/src/reserves/dto/update-reserve.dto.ts b/src/reserves/dto/update-reserve.dto.ts new file mode 100644 index 0000000..cfc6289 --- /dev/null +++ b/src/reserves/dto/update-reserve.dto.ts @@ -0,0 +1,44 @@ +import { + IsDateString, + IsEnum, + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, +} from 'class-validator'; +import { ReserveStatus } from '../enums/reserve-status.enum'; + +export class UpdateReserveDto { + @IsOptional() + @IsDateString() + appointmentDate?: string; + + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/) + startTime?: string; + + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/) + endTime?: string; + + @IsOptional() + @IsEnum(ReserveStatus) + status?: ReserveStatus; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + totalAmount?: number; + + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + depositAmount?: number; + + @IsOptional() + @IsString() + @MaxLength(1000) + cancellationReason?: string; +} diff --git a/src/reserves/entities/reserve-item.entity.ts b/src/reserves/entities/reserve-item.entity.ts new file mode 100644 index 0000000..95b9af5 --- /dev/null +++ b/src/reserves/entities/reserve-item.entity.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ServiceSkill } from '../../services/entities/service-skill.entity'; +import { Service } from '../../services/entities/service.entity'; +import type { Reserve } from './reserve.entity.js'; + +@Entity('reserve_items') +@Index(['reserveId']) +export class ReserveItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + reserveId: string; + + @ManyToOne('Reserve', 'items', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'reserveId' }) + reserve: Reserve; + + @Column() + serviceId: string; + + @ManyToOne(() => Service, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'serviceId' }) + service: Service; + + @Column() + serviceSkillId: string; + + @ManyToOne(() => ServiceSkill, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'serviceSkillId' }) + serviceSkill: ServiceSkill; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/reserves/entities/reserve.entity.ts b/src/reserves/entities/reserve.entity.ts new file mode 100644 index 0000000..7ba110e --- /dev/null +++ b/src/reserves/entities/reserve.entity.ts @@ -0,0 +1,105 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Customer } from '../../customers/entities/customer.entity'; +import { Salon } from '../../salons/entities/salon.entity'; +import { Stylist } from '../../stylists/entities/stylist.entity'; +import { ReserveStatus } from '../enums/reserve-status.enum'; +import type { ReserveItem } from './reserve-item.entity.js'; + +const decimalTransformer = { + to: (value: number) => value, + from: (value: string) => parseFloat(value), +}; + +@Entity('reserves') +@Index(['salonId', 'appointmentDate']) +@Index(['stylistId', 'appointmentDate']) +@Index(['customerId']) +@Index(['status']) +export class Reserve { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column() + customerId: string; + + @ManyToOne(() => Customer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customerId' }) + customer: Customer; + + @Column() + stylistId: string; + + @ManyToOne(() => Stylist, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'stylistId' }) + stylist: Stylist; + + @Column({ type: 'date' }) + appointmentDate: string; + + @Column({ type: 'time' }) + startTime: string; + + @Column({ type: 'time' }) + endTime: string; + + @Column({ + type: 'enum', + enum: ReserveStatus, + enumName: 'reserves_status_enum', + default: ReserveStatus.PENDING, + }) + status: ReserveStatus; + + @Column({ + type: 'decimal', + precision: 12, + scale: 2, + transformer: decimalTransformer, + }) + totalAmount: number; + + @Column({ + type: 'decimal', + precision: 12, + scale: 2, + transformer: decimalTransformer, + }) + depositAmount: number; + + @Column({ + type: 'decimal', + precision: 12, + scale: 2, + transformer: decimalTransformer, + }) + remainingAmount: number; + + @Column({ type: 'text', nullable: true }) + cancellationReason: string | null; + + @OneToMany('ReserveItem', 'reserve', { cascade: true }) + items: ReserveItem[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/reserves/enums/reserve-status.enum.ts b/src/reserves/enums/reserve-status.enum.ts new file mode 100644 index 0000000..e8af06b --- /dev/null +++ b/src/reserves/enums/reserve-status.enum.ts @@ -0,0 +1,7 @@ +export enum ReserveStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + NO_SHOW = 'no_show', +} diff --git a/src/reserves/reserves.controller.ts b/src/reserves/reserves.controller.ts new file mode 100644 index 0000000..872bc56 --- /dev/null +++ b/src/reserves/reserves.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateReserveDto } from './dto/create-reserve.dto'; +import { FindReservesQueryDto } from './dto/find-reserves-query.dto'; +import { UpdateReserveDto } from './dto/update-reserve.dto'; +import { ReservesService } from './reserves.service'; + +@Controller('reserves') +@UseGuards(RolesGuard) +export class ReservesController { + constructor(private readonly reservesService: ReservesService) {} + + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @Post() + create( + @Body() createReserveDto: CreateReserveDto, + @CurrentUser() user: AuthUser, + ) { + return this.reservesService.create(createReserveDto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @Get() + findAll( + @Query() query: FindReservesQueryDto, + @CurrentUser() user: AuthUser, + ) { + return this.reservesService.findAll(user, query); + } + + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @Get(':id') + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.reservesService.findOne(id, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateReserveDto: UpdateReserveDto, + @CurrentUser() user: AuthUser, + ) { + return this.reservesService.update(id, updateReserveDto, user); + } +} diff --git a/src/reserves/reserves.module.ts b/src/reserves/reserves.module.ts new file mode 100644 index 0000000..2adf66d --- /dev/null +++ b/src/reserves/reserves.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { CustomersModule } from '../customers/customers.module'; +import { SalonsModule } from '../salons/salons.module'; +import { ServiceSkill } from '../services/entities/service-skill.entity'; +import { Service } from '../services/entities/service.entity'; +import { StylistsModule } from '../stylists/stylists.module'; +import { ReserveItem } from './entities/reserve-item.entity'; +import { Reserve } from './entities/reserve.entity'; +import { ReservesController } from './reserves.controller'; +import { ReservesService } from './reserves.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Reserve, ReserveItem, Service, ServiceSkill]), + SalonsModule, + CustomersModule, + StylistsModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [ReservesController], + providers: [ReservesService], + exports: [ReservesService], +}) +export class ReservesModule {} diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts new file mode 100644 index 0000000..ce3ab40 --- /dev/null +++ b/src/reserves/reserves.service.ts @@ -0,0 +1,262 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthorizationService } from '../authorization/authorization.service'; +import { ReservePolicy } from '../authorization/policies/reserve.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { CustomersService } from '../customers/customers.service'; +import { SalonsService } from '../salons/salons.service'; +import { ServiceSkill } from '../services/entities/service-skill.entity'; +import { Service } from '../services/entities/service.entity'; +import { StylistsService } from '../stylists/stylists.service'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateReserveDto } from './dto/create-reserve.dto'; +import { FindReservesQueryDto } from './dto/find-reserves-query.dto'; +import { UpdateReserveDto } from './dto/update-reserve.dto'; +import { ReserveItem } from './entities/reserve-item.entity'; +import { Reserve } from './entities/reserve.entity'; +import { ReserveStatus } from './enums/reserve-status.enum'; + +@Injectable() +export class ReservesService { + constructor( + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, + @InjectRepository(ReserveItem) + private readonly reserveItemsRepository: Repository, + @InjectRepository(Service) + private readonly servicesRepository: Repository, + @InjectRepository(ServiceSkill) + private readonly serviceSkillsRepository: Repository, + private readonly salonsService: SalonsService, + private readonly customersService: CustomersService, + private readonly stylistsService: StylistsService, + private readonly authorizationService: AuthorizationService, + private readonly reservePolicy: ReservePolicy, + ) {} + + async create( + createReserveDto: CreateReserveDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(createReserveDto.salonId); + const customer = await this.customersService.findOne( + createReserveDto.customerId, + ); + const stylist = await this.stylistsService.findOne( + createReserveDto.stylistId, + ); + + if (stylist.salonId !== salon.id) { + throw new BadRequestException( + 'Stylist does not belong to the selected salon', + ); + } + + if (!this.reservePolicy.canCreate(user, { salon, customer })) { + throw new ForbiddenException(); + } + + this.validateTimeRange(createReserveDto.startTime, createReserveDto.endTime); + this.validateAmounts( + createReserveDto.totalAmount, + createReserveDto.depositAmount, + ); + await this.validateItems(createReserveDto.items); + + const reserve = this.reservesRepository.create({ + salonId: createReserveDto.salonId, + customerId: createReserveDto.customerId, + stylistId: createReserveDto.stylistId, + appointmentDate: createReserveDto.appointmentDate, + startTime: this.normalizeTime(createReserveDto.startTime), + endTime: this.normalizeTime(createReserveDto.endTime), + totalAmount: createReserveDto.totalAmount, + depositAmount: createReserveDto.depositAmount, + remainingAmount: + createReserveDto.totalAmount - createReserveDto.depositAmount, + items: createReserveDto.items.map((item) => + this.reserveItemsRepository.create(item), + ), + }); + + const saved = await this.reservesRepository.save(reserve); + return this.findOne(saved.id, user); + } + + async findAll( + user: AuthUser, + query: FindReservesQueryDto, + ): Promise { + const qb = this.reservesRepository + .createQueryBuilder('reserve') + .leftJoinAndSelect('reserve.salon', 'salon') + .leftJoinAndSelect('reserve.customer', 'customer') + .leftJoinAndSelect('customer.user', 'customerUser') + .leftJoinAndSelect('reserve.stylist', 'stylist') + .leftJoinAndSelect('stylist.user', 'stylistUser') + .leftJoinAndSelect('reserve.items', 'items') + .leftJoinAndSelect('items.service', 'service') + .leftJoinAndSelect('items.serviceSkill', 'serviceSkill') + .orderBy('reserve.appointmentDate', 'ASC') + .addOrderBy('reserve.startTime', 'ASC'); + + if (query.salonId) { + qb.andWhere('reserve.salonId = :salonId', { salonId: query.salonId }); + } + if (query.customerId) { + qb.andWhere('reserve.customerId = :customerId', { + customerId: query.customerId, + }); + } + if (query.stylistId) { + qb.andWhere('reserve.stylistId = :stylistId', { + stylistId: query.stylistId, + }); + } + if (query.status) { + qb.andWhere('reserve.status = :status', { status: query.status }); + } + if (query.appointmentDate) { + qb.andWhere('reserve.appointmentDate = :appointmentDate', { + appointmentDate: query.appointmentDate, + }); + } + + if (!this.authorizationService.isAdmin(user)) { + if (user.role === UserRole.SALON) { + qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id }); + } else if (user.role === UserRole.CUSTOMER) { + qb.andWhere('customer.userId = :userId', { userId: user.id }); + } else { + throw new ForbiddenException(); + } + } + + return qb.getMany(); + } + + async findOne(id: string, user: AuthUser): Promise { + const reserve = await this.reservesRepository.findOne({ + where: { id }, + relations: this.defaultRelations, + }); + if (!reserve) { + throw new NotFoundException(`Reserve #${id} not found`); + } + if (!this.reservePolicy.canRead(user, reserve)) { + throw new ForbiddenException(); + } + return reserve; + } + + async update( + id: string, + updateReserveDto: UpdateReserveDto, + user: AuthUser, + ): Promise { + const reserve = await this.findOne(id, user); + if (!this.reservePolicy.canUpdate(user, reserve)) { + throw new ForbiddenException(); + } + + const { + appointmentDate, + startTime, + endTime, + status, + totalAmount, + depositAmount, + cancellationReason, + } = updateReserveDto; + + if (appointmentDate !== undefined) { + reserve.appointmentDate = appointmentDate; + } + if (startTime !== undefined) { + reserve.startTime = this.normalizeTime(startTime); + } + if (endTime !== undefined) { + reserve.endTime = this.normalizeTime(endTime); + } + + this.validateTimeRange(reserve.startTime, reserve.endTime); + + if (totalAmount !== undefined) { + reserve.totalAmount = totalAmount; + } + if (depositAmount !== undefined) { + reserve.depositAmount = depositAmount; + } + if (totalAmount !== undefined || depositAmount !== undefined) { + this.validateAmounts(reserve.totalAmount, reserve.depositAmount); + reserve.remainingAmount = reserve.totalAmount - reserve.depositAmount; + } + + if (status !== undefined) { + reserve.status = status; + if (status === ReserveStatus.CANCELLED) { + reserve.cancellationReason = cancellationReason ?? null; + } else if (cancellationReason !== undefined) { + reserve.cancellationReason = cancellationReason; + } + } else if (cancellationReason !== undefined) { + reserve.cancellationReason = cancellationReason; + } + + await this.reservesRepository.save(reserve); + return this.findOne(id, user); + } + + private readonly defaultRelations = { + salon: true, + customer: { user: true }, + stylist: { user: true }, + items: { service: true, serviceSkill: true }, + } as const; + + private validateTimeRange(startTime: string, endTime: string): void { + if (startTime >= endTime) { + throw new BadRequestException('endTime must be after startTime'); + } + } + + private validateAmounts(totalAmount: number, depositAmount: number): void { + if (depositAmount > totalAmount) { + throw new BadRequestException( + 'depositAmount cannot exceed totalAmount', + ); + } + } + + private normalizeTime(time: string): string { + return time.length === 5 ? `${time}:00` : time; + } + + private async validateItems( + items: CreateReserveDto['items'], + ): Promise { + for (const item of items) { + const serviceExists = await this.servicesRepository.existsBy({ + id: item.serviceId, + }); + if (!serviceExists) { + throw new BadRequestException(`Service #${item.serviceId} not found`); + } + + const skill = await this.serviceSkillsRepository.findOne({ + where: { id: item.serviceSkillId, serviceId: item.serviceId }, + }); + if (!skill) { + throw new BadRequestException( + `Service skill #${item.serviceSkillId} not found for service #${item.serviceId}`, + ); + } + } + } +}