Files
grow-api/src/reserves/reserves.service.ts
T
2026-07-09 21:07:39 +03:30

412 lines
13 KiB
TypeScript

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<Reserve>,
@InjectRepository(ReserveItem)
private readonly reserveItemsRepository: Repository<ReserveItem>,
@InjectRepository(ReserveStatusHistory)
private readonly reserveStatusHistoryRepository: Repository<ReserveStatusHistory>,
@InjectRepository(Service)
private readonly servicesRepository: Repository<Service>,
@InjectRepository(ServiceSkill)
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
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<Reserve> {
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<Reserve[]> {
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<Reserve> {
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<Reserve> {
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<ReserveStatusHistory[]> {
await this.findOne(id, user);
return this.reserveStatusHistoryRepository.find({
where: { reserveId: id },
relations: { changedByUser: true },
order: { createdAt: 'ASC' },
});
}
async autoCompletePastReserves(): Promise<number> {
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<void> {
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<void> {
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<void> {
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}`,
);
}
}
}
}