reserves module

This commit is contained in:
2026-06-29 08:39:34 +03:30
parent 127c1eaf78
commit d8086cab17
15 changed files with 871 additions and 0 deletions
+262
View File
@@ -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<Reserve>,
@InjectRepository(ReserveItem)
private readonly reserveItemsRepository: Repository<ReserveItem>,
@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,
) {}
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);
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<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 (!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)) {
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<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}`,
);
}
}
}
}