import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, 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 { ServicesService } from '../services/services.service'; 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 { ReserveStatusHistory } from './entities/reserve-status-history.entity'; import { Reserve } from './entities/reserve.entity'; import { SalonNotificationsService } from '../notifications/salon-notifications.service'; import { ReserveSmsService } from '../sms-settings/reserve-sms.service'; 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(ReserveStatusHistory) private readonly reserveStatusHistoryRepository: 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, private readonly reserveSmsService: ReserveSmsService, private readonly salonNotificationsService: SalonNotificationsService, private readonly servicesService: ServicesService, ) {} 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); await this.servicesService.ensureSalonOffersServices( createReserveDto.salonId, createReserveDto.items.map((item) => item.serviceId), ); 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); await this.customersService.linkToSalon(saved.salonId, saved.customerId); await this.logStatusChange(saved.id, null, saved.status, user.id); await this.reserveSmsService.sendReserveCreated(saved.id); await this.salonNotificationsService.notifyReserveCreated(saved.id); 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 (query.appointmentDateFrom && query.appointmentDateTo) { qb.andWhere( 'reserve.appointmentDate BETWEEN :appointmentDateFrom AND :appointmentDateTo', { appointmentDateFrom: query.appointmentDateFrom, appointmentDateTo: query.appointmentDateTo, }, ); } else if (query.appointmentDateFrom) { qb.andWhere('reserve.appointmentDate >= :appointmentDateFrom', { appointmentDateFrom: query.appointmentDateFrom, }); } else if (query.appointmentDateTo) { qb.andWhere('reserve.appointmentDate <= :appointmentDateTo', { appointmentDateTo: query.appointmentDateTo, }); } if (query.serviceId) { qb.andWhere( `EXISTS ( SELECT 1 FROM reserve_items serviceFilterItem WHERE serviceFilterItem."reserveId" = reserve.id AND serviceFilterItem."serviceId" = :serviceId )`, { serviceId: query.serviceId }, ); } 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, updateReserveDto)) { 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) { const previousStatus = reserve.status; if (status !== reserve.status) { await this.logStatusChange(reserve.id, reserve.status, status, user.id); } reserve.status = status; if (status === ReserveStatus.CANCELLED) { reserve.cancellationReason = cancellationReason ?? null; } else if (cancellationReason !== undefined) { reserve.cancellationReason = cancellationReason; } await this.reservesRepository.save(reserve); if ( status === ReserveStatus.CANCELLED && previousStatus !== ReserveStatus.CANCELLED ) { await this.reserveSmsService.sendReserveCancelled(reserve.id); await this.salonNotificationsService.notifyReserveCancelled(reserve.id); } return this.findOne(id, user); } if (cancellationReason !== undefined) { reserve.cancellationReason = cancellationReason; } await this.reservesRepository.save(reserve); return this.findOne(id, user); } async findStatusHistory( id: string, user: AuthUser, ): Promise { await this.findOne(id, user); return this.reserveStatusHistoryRepository.find({ where: { reserveId: id }, relations: { changedByUser: true }, order: { createdAt: 'ASC' }, }); } async autoCompletePastReserves(): Promise { const today = this.getTodayDateString(); const reserves = await this.reservesRepository.find({ where: { status: In([ReserveStatus.PENDING, ReserveStatus.CONFIRMED]), appointmentDate: LessThan(today), }, }); for (const reserve of reserves) { await this.logStatusChange( reserve.id, reserve.status, ReserveStatus.COMPLETED, null, ); reserve.status = ReserveStatus.COMPLETED; } if (reserves.length > 0) { await this.reservesRepository.save(reserves); } return reserves.length; } async confirmDepositPayment( reserveId: string, changedByUserId: string | null, ): Promise { const reserve = await this.reservesRepository.findOne({ where: { id: reserveId }, }); if (!reserve) { throw new NotFoundException(`Reserve #${reserveId} not found`); } if (reserve.status !== ReserveStatus.PENDING) { return; } await this.logStatusChange( reserve.id, reserve.status, ReserveStatus.CONFIRMED, changedByUserId, ); reserve.status = ReserveStatus.CONFIRMED; await this.reservesRepository.save(reserve); await this.reserveSmsService.sendReserveConfirmed(reserve.id); await this.salonNotificationsService.notifyReserveConfirmed(reserve.id); } private async logStatusChange( reserveId: string, fromStatus: ReserveStatus | null, toStatus: ReserveStatus, changedByUserId: string | null, ): Promise { const entry = this.reserveStatusHistoryRepository.create({ reserveId, fromStatus, toStatus, changedByUserId, }); await this.reserveStatusHistoryRepository.save(entry); } 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 getTodayDateString(): string { return new Date().toISOString().slice(0, 10); } 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}`, ); } } } }