412 lines
13 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|