1130 lines
35 KiB
TypeScript
1130 lines
35 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { randomUUID } from 'crypto';
|
|
import { In, Repository } from 'typeorm';
|
|
import {
|
|
generateAvailableSlotStarts,
|
|
parseGregorianYmd,
|
|
resolveServiceDurationMinutes,
|
|
tehranYmd,
|
|
type AvailabilityWindow,
|
|
type BusyInterval,
|
|
} from '../../common/utils/booking-schedule.util';
|
|
import { BookingEntity, BookingStatus } from '../../entities/booking.entity';
|
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
|
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
|
|
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
|
import { BusinessEntity } from '../../entities/business.entity';
|
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
|
import { AuthUser } from '../auth/auth.types';
|
|
import { SmsService } from '../auth/sms.service';
|
|
import { ZarinpalService } from '../payments/zarinpal.service';
|
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
|
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
|
|
|
type FindAllFilters = {
|
|
customerId?: string;
|
|
businessId?: string;
|
|
status?: BookingStatus;
|
|
scope?: 'mine';
|
|
};
|
|
|
|
@Injectable()
|
|
export class BookingsService {
|
|
private readonly logger = new Logger(BookingsService.name);
|
|
private readonly minLeadMinutes: number;
|
|
private readonly webAppUrl: string;
|
|
private readonly apiPublicUrl: string;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly sms: SmsService,
|
|
private readonly zarinpal: ZarinpalService,
|
|
@InjectRepository(BookingEntity)
|
|
private readonly repo: Repository<BookingEntity>,
|
|
@InjectRepository(UserEntity)
|
|
private readonly userRepo: Repository<UserEntity>,
|
|
@InjectRepository(BusinessEntity)
|
|
private readonly businessRepo: Repository<BusinessEntity>,
|
|
@InjectRepository(BusinessServiceEntity)
|
|
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
|
|
@InjectRepository(BusinessAvailabilitySlotEntity)
|
|
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
|
@InjectRepository(BusinessServiceClassEntity)
|
|
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
|
|
@InjectRepository(BusinessServiceClassPriceEntity)
|
|
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
|
) {
|
|
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
|
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
|
this.minLeadMinutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : 60;
|
|
this.webAppUrl = (this.config.get<string>('WEB_APP_URL') ?? 'https://montime.ir').replace(
|
|
/\/$/,
|
|
'',
|
|
);
|
|
this.apiPublicUrl = (
|
|
this.config.get<string>('API_PUBLIC_URL') ?? 'https://api.montime.ir'
|
|
).replace(/\/$/, '');
|
|
}
|
|
|
|
async findAll(
|
|
filters: FindAllFilters,
|
|
actor?: AuthUser,
|
|
): Promise<BookingEntity[]> {
|
|
const qb = this.repo
|
|
.createQueryBuilder('b')
|
|
.leftJoinAndSelect('b.customer', 'customer')
|
|
.leftJoinAndSelect('b.business', 'business')
|
|
.leftJoinAndSelect('b.businessService', 'businessService')
|
|
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
|
|
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
|
|
.orderBy('b.scheduledAt', 'ASC');
|
|
|
|
if (actor?.role === UserRole.CUSTOMER) {
|
|
qb.andWhere('b.customerId = :actorCustomerId', {
|
|
actorCustomerId: actor.id,
|
|
});
|
|
} else if (actor?.role === UserRole.BUSINESS_OWNER) {
|
|
qb.andWhere(
|
|
'(business.ownerId = :actorOwnerId OR b.customerId = :actorCustomerId)',
|
|
{ actorOwnerId: actor.id, actorCustomerId: actor.id },
|
|
);
|
|
} else if (actor?.role === UserRole.ADMIN) {
|
|
if (filters.scope === 'mine') {
|
|
qb.andWhere('b.customerId = :actorCustomerId', {
|
|
actorCustomerId: actor.id,
|
|
});
|
|
} else if (filters.customerId) {
|
|
qb.andWhere('b.customerId = :customerId', {
|
|
customerId: filters.customerId,
|
|
});
|
|
}
|
|
} else if (filters.customerId) {
|
|
qb.andWhere('b.customerId = :customerId', {
|
|
customerId: filters.customerId,
|
|
});
|
|
}
|
|
if (filters.businessId) {
|
|
qb.andWhere('b.businessId = :businessId', {
|
|
businessId: filters.businessId,
|
|
});
|
|
}
|
|
if (filters.status) {
|
|
qb.andWhere('b.status = :status', { status: filters.status });
|
|
}
|
|
|
|
return qb.getMany();
|
|
}
|
|
|
|
async findOne(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
|
const row = await this.repo.findOne({
|
|
where: { id },
|
|
relations: {
|
|
customer: true,
|
|
business: true,
|
|
businessService: { serviceTemplate: true },
|
|
serviceClass: true,
|
|
},
|
|
});
|
|
if (!row) {
|
|
throw new NotFoundException('Booking not found');
|
|
}
|
|
this.assertCanViewBooking(row, actor);
|
|
return row;
|
|
}
|
|
|
|
async create(dto: CreateBookingDto, actor?: AuthUser): Promise<BookingEntity> {
|
|
if (actor && dto.customerId !== actor.id) {
|
|
throw new ForbiddenException('You can only book for yourself');
|
|
}
|
|
|
|
const customer = await this.userRepo.findOne({
|
|
where: { id: dto.customerId },
|
|
});
|
|
if (!customer) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
const business = await this.businessRepo.findOne({
|
|
where: { id: dto.businessId },
|
|
});
|
|
if (!business) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const businessService = await this.businessServiceRepo.findOne({
|
|
where: { id: dto.businessServiceId },
|
|
relations: { business: true, serviceTemplate: true },
|
|
});
|
|
if (!businessService) {
|
|
throw new NotFoundException('Business service not found');
|
|
}
|
|
|
|
if (businessService.businessId !== dto.businessId) {
|
|
throw new BadRequestException(
|
|
'businessService does not belong to the given business',
|
|
);
|
|
}
|
|
|
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
|
const pricing = await this.resolveBookingPricing(
|
|
businessService,
|
|
dto.serviceClassId ?? null,
|
|
);
|
|
await this.assertSchedulableSlot({
|
|
businessId: dto.businessId,
|
|
scheduledAt,
|
|
durationMinutes: pricing.durationMinutes,
|
|
});
|
|
|
|
const entity = this.repo.create({
|
|
customerId: dto.customerId,
|
|
businessId: dto.businessId,
|
|
businessServiceId: dto.businessServiceId,
|
|
serviceClassId: pricing.serviceClassId,
|
|
scheduledAt,
|
|
note: dto.note ?? null,
|
|
status: BookingStatus.PENDING,
|
|
});
|
|
|
|
const saved = await this.repo.save(entity);
|
|
if (business.autoApproveBookings) {
|
|
return this.approveForPayment(saved.id, actor, { skipNotify: false });
|
|
}
|
|
return this.findOne(saved.id, actor);
|
|
}
|
|
|
|
async createBatch(
|
|
dto: CreateBookingBatchDto,
|
|
actor?: AuthUser,
|
|
): Promise<BookingEntity[]> {
|
|
if (actor && dto.customerId !== actor.id) {
|
|
throw new ForbiddenException('You can only book for yourself');
|
|
}
|
|
|
|
const customer = await this.userRepo.findOne({
|
|
where: { id: dto.customerId },
|
|
});
|
|
if (!customer) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
const business = await this.businessRepo.findOne({
|
|
where: { id: dto.businessId },
|
|
});
|
|
if (!business) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
|
|
const serviceIds = [
|
|
...new Set(dto.items.map((item) => item.businessServiceId)),
|
|
];
|
|
if (serviceIds.length !== dto.items.length) {
|
|
throw new BadRequestException('Duplicate services in booking batch');
|
|
}
|
|
|
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
|
const serviceClassId = dto.serviceClassId ?? null;
|
|
|
|
const resolvedItems: Array<{
|
|
businessService: BusinessServiceEntity;
|
|
serviceClassId: string | null;
|
|
durationMinutes: number;
|
|
}> = [];
|
|
|
|
let totalDurationMinutes = 0;
|
|
for (const serviceId of serviceIds) {
|
|
const businessService = await this.businessServiceRepo.findOne({
|
|
where: { id: serviceId, businessId: dto.businessId, isActive: true },
|
|
relations: { serviceTemplate: true },
|
|
});
|
|
if (!businessService) {
|
|
throw new NotFoundException(
|
|
`Business service ${serviceId} not found or inactive`,
|
|
);
|
|
}
|
|
|
|
const pricing = await this.resolveBookingPricing(
|
|
businessService,
|
|
serviceClassId,
|
|
);
|
|
resolvedItems.push({
|
|
businessService,
|
|
serviceClassId: pricing.serviceClassId,
|
|
durationMinutes: pricing.durationMinutes,
|
|
});
|
|
totalDurationMinutes += pricing.durationMinutes;
|
|
}
|
|
|
|
await this.assertSchedulableSlot({
|
|
businessId: dto.businessId,
|
|
scheduledAt,
|
|
durationMinutes: totalDurationMinutes,
|
|
});
|
|
|
|
const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null;
|
|
const note = dto.note ?? null;
|
|
|
|
const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) =>
|
|
this.repo.create({
|
|
customerId: dto.customerId,
|
|
businessId: dto.businessId,
|
|
businessServiceId: businessService.id,
|
|
serviceClassId: classId,
|
|
bookingGroupId,
|
|
scheduledAt,
|
|
note,
|
|
status: BookingStatus.PENDING,
|
|
}),
|
|
);
|
|
|
|
const saved = await this.repo.save(entities);
|
|
if (business.autoApproveBookings && saved.length) {
|
|
await this.approveForPaymentGroup(saved[0].id, actor, { skipNotify: false });
|
|
return Promise.all(saved.map((row) => this.findOne(row.id, actor)));
|
|
}
|
|
return Promise.all(saved.map((row) => this.findOne(row.id, actor)));
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateBookingDto,
|
|
actor?: AuthUser,
|
|
): Promise<BookingEntity> {
|
|
const row = await this.findOne(id);
|
|
this.assertCanManageBooking(row, actor);
|
|
this.assertBookingIsMutable(row);
|
|
|
|
if (dto.scheduledAt !== undefined) {
|
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
|
const durationMinutes = await this.resolveGroupDuration(row);
|
|
await this.assertSchedulableSlot({
|
|
businessId: row.businessId,
|
|
scheduledAt,
|
|
durationMinutes,
|
|
excludeBookingId: row.id,
|
|
excludeBookingGroupId: row.bookingGroupId ?? undefined,
|
|
});
|
|
row.scheduledAt = scheduledAt;
|
|
if (row.bookingGroupId) {
|
|
await this.repo.update(
|
|
{ bookingGroupId: row.bookingGroupId },
|
|
{ scheduledAt },
|
|
);
|
|
}
|
|
}
|
|
|
|
if (dto.status !== undefined) {
|
|
this.applyStatusTransition(row, dto.status, actor);
|
|
}
|
|
|
|
if (dto.note !== undefined) {
|
|
row.note = dto.note;
|
|
}
|
|
|
|
return this.repo.save(row);
|
|
}
|
|
|
|
async confirm(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
|
const row = await this.findOne(id, actor);
|
|
this.assertCanManageBooking(row, actor);
|
|
return this.approveForPayment(id, actor, { skipNotify: false });
|
|
}
|
|
|
|
async complete(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
|
const row = await this.findOne(id);
|
|
this.assertCanManageBooking(row, actor);
|
|
this.applyStatusTransition(row, BookingStatus.COMPLETED, actor);
|
|
return this.repo.save(row);
|
|
}
|
|
|
|
async cancel(
|
|
id: string,
|
|
actor?: AuthUser,
|
|
payload?: { cancellationReason?: string },
|
|
): Promise<BookingEntity> {
|
|
const row = await this.findOne(id);
|
|
this.assertCanCancelBooking(row, actor);
|
|
|
|
const reason = payload?.cancellationReason?.trim() ?? '';
|
|
if (actor?.role === UserRole.BUSINESS_OWNER && !reason) {
|
|
throw new BadRequestException('Cancellation reason is required');
|
|
}
|
|
if (reason) {
|
|
row.cancellationReason = reason;
|
|
}
|
|
|
|
this.applyStatusTransition(row, BookingStatus.CANCELLED, actor);
|
|
return this.repo.save(row);
|
|
}
|
|
|
|
async findByPaymentToken(
|
|
token: string,
|
|
actor?: AuthUser,
|
|
): Promise<{ bookings: BookingEntity[]; totalAmountToman: number }> {
|
|
const bookings = await this.repo.find({
|
|
where: { paymentToken: token },
|
|
relations: {
|
|
customer: true,
|
|
business: true,
|
|
businessService: { serviceTemplate: true },
|
|
serviceClass: true,
|
|
},
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
if (!bookings.length) {
|
|
throw new NotFoundException('Payment link not found');
|
|
}
|
|
for (const booking of bookings) {
|
|
this.assertCanViewBooking(booking, actor);
|
|
}
|
|
const totalAmountToman = bookings.reduce(
|
|
(sum, booking) => sum + Number.parseInt(booking.paymentAmount ?? '0', 10),
|
|
0,
|
|
);
|
|
return { bookings, totalAmountToman };
|
|
}
|
|
|
|
async initiatePayment(
|
|
id: string,
|
|
actor: AuthUser,
|
|
): Promise<{ startUrl: string; amountToman: number; mock?: boolean }> {
|
|
const row = await this.findOne(id, actor);
|
|
if (row.customerId !== actor.id && actor.role !== UserRole.ADMIN) {
|
|
throw new ForbiddenException('Only the customer can pay for this booking');
|
|
}
|
|
if (row.status !== BookingStatus.AWAITING_PAYMENT) {
|
|
throw new BadRequestException('This booking is not awaiting payment');
|
|
}
|
|
|
|
const group = await this.loadPaymentGroup(row);
|
|
const amountToman = await this.resolveGroupAmountToman(group);
|
|
const leader = group[0]!;
|
|
const description = `رزرو ${leader.business?.title ?? 'منتایم'}`;
|
|
|
|
const payment = await this.zarinpal.requestPayment({
|
|
amountToman,
|
|
description,
|
|
callbackUrl: `${this.apiPublicUrl}/bookings/payment/callback`,
|
|
mobile: leader.customer?.mobile,
|
|
});
|
|
|
|
if (payment.mock) {
|
|
await this.completePaymentGroup(group, payment.authority);
|
|
return { startUrl: '', amountToman, mock: true };
|
|
}
|
|
|
|
for (const row of group) {
|
|
row.paymentAuthority = payment.authority;
|
|
}
|
|
await this.repo.save(group);
|
|
|
|
return { startUrl: payment.startUrl, amountToman };
|
|
}
|
|
|
|
async initiatePaymentByToken(
|
|
token: string,
|
|
actor: AuthUser,
|
|
): Promise<{ startUrl: string; amountToman: number }> {
|
|
const { bookings } = await this.findByPaymentToken(token, actor);
|
|
const leader = bookings[0]!;
|
|
return this.initiatePayment(leader.id, actor);
|
|
}
|
|
|
|
async handlePaymentCallback(params: {
|
|
authority: string;
|
|
status: string;
|
|
}): Promise<{ redirectUrl: string }> {
|
|
if (params.status !== 'OK') {
|
|
return { redirectUrl: `${this.webAppUrl}/app/bookings?payment=failed` };
|
|
}
|
|
|
|
const leader = await this.repo.findOne({
|
|
where: { paymentAuthority: params.authority },
|
|
relations: { customer: true, business: true },
|
|
});
|
|
if (!leader) {
|
|
return { redirectUrl: `${this.webAppUrl}/app/bookings?payment=failed` };
|
|
}
|
|
|
|
const group = await this.loadPaymentGroup(leader);
|
|
const amountToman = await this.resolveGroupAmountToman(group);
|
|
const token = leader.paymentToken;
|
|
|
|
try {
|
|
await this.zarinpal.verifyPayment({
|
|
authority: params.authority,
|
|
amountToman,
|
|
});
|
|
} catch (error) {
|
|
this.logger.warn(`Payment verify failed: ${String(error)}`);
|
|
const failUrl = token
|
|
? `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}&payment=failed`
|
|
: `${this.webAppUrl}/app/bookings?payment=failed`;
|
|
return { redirectUrl: failUrl };
|
|
}
|
|
|
|
await this.completePaymentGroup(group, params.authority);
|
|
|
|
const successUrl = token
|
|
? `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}&payment=success`
|
|
: `${this.webAppUrl}/app/bookings?payment=success`;
|
|
return { redirectUrl: successUrl };
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
const result = await this.repo.delete(id);
|
|
if (!result.affected) {
|
|
throw new NotFoundException('Booking not found');
|
|
}
|
|
}
|
|
|
|
async getAvailableSlots(
|
|
businessId: string,
|
|
dateYmd: string,
|
|
options: {
|
|
businessServiceId?: string;
|
|
businessServiceIds?: string[];
|
|
serviceClassId?: string;
|
|
},
|
|
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
|
|
parseGregorianYmd(dateYmd);
|
|
|
|
const business = await this.businessRepo.findOne({
|
|
where: { id: businessId, isActive: true },
|
|
});
|
|
if (!business) {
|
|
throw new NotFoundException('Business not found or inactive');
|
|
}
|
|
|
|
const serviceIds =
|
|
options.businessServiceIds?.length
|
|
? options.businessServiceIds
|
|
: options.businessServiceId
|
|
? [options.businessServiceId]
|
|
: [];
|
|
|
|
if (!serviceIds.length) {
|
|
throw new BadRequestException('At least one business service is required');
|
|
}
|
|
|
|
const serviceClassId = options.serviceClassId ?? null;
|
|
let totalDurationMinutes = 0;
|
|
|
|
for (const serviceId of serviceIds) {
|
|
const businessService = await this.businessServiceRepo.findOne({
|
|
where: { id: serviceId, businessId, isActive: true },
|
|
relations: { serviceTemplate: true },
|
|
});
|
|
if (!businessService) {
|
|
throw new NotFoundException('Business service not found or inactive');
|
|
}
|
|
|
|
const pricing = await this.resolveBookingPricing(
|
|
businessService,
|
|
serviceClassId,
|
|
);
|
|
totalDurationMinutes += pricing.durationMinutes;
|
|
}
|
|
|
|
const windows = await this.loadAvailabilityWindows(businessId);
|
|
const busy = await this.loadBusyIntervals(businessId, dateYmd);
|
|
const { concurrentCapacity, slotStepMinutes } =
|
|
await this.getBusinessBookingSettings(businessId);
|
|
|
|
const slots = generateAvailableSlotStarts({
|
|
dateYmd,
|
|
durationMinutes: totalDurationMinutes,
|
|
leadMinutes: this.minLeadMinutes,
|
|
slotStepMinutes,
|
|
windows,
|
|
busy,
|
|
concurrentCapacity,
|
|
});
|
|
|
|
return { date: dateYmd, slots, totalDurationMinutes };
|
|
}
|
|
|
|
private async getBusinessBookingSettings(businessId: string): Promise<{
|
|
concurrentCapacity: number;
|
|
slotStepMinutes: number;
|
|
}> {
|
|
const business = await this.businessRepo.findOne({
|
|
where: { id: businessId },
|
|
select: ['id', 'concurrentCapacity', 'slotStepMinutes'],
|
|
});
|
|
return {
|
|
concurrentCapacity: Math.max(1, business?.concurrentCapacity ?? 1),
|
|
slotStepMinutes: Math.max(5, business?.slotStepMinutes ?? 30),
|
|
};
|
|
}
|
|
|
|
private async loadAvailabilityWindows(
|
|
businessId: string,
|
|
): Promise<AvailabilityWindow[]> {
|
|
const rows = await this.availabilityRepo.find({ where: { businessId } });
|
|
return rows.map((r) => ({
|
|
dayOfWeek: r.dayOfWeek,
|
|
startMinute: r.startMinute,
|
|
endMinute: r.endMinute,
|
|
}));
|
|
}
|
|
|
|
private async loadBusyIntervals(
|
|
businessId: string,
|
|
dateYmd: string,
|
|
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
|
|
): Promise<BusyInterval[]> {
|
|
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
|
|
const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`);
|
|
|
|
let excludeGroupId = options?.excludeBookingGroupId ?? null;
|
|
if (options?.excludeBookingId && !excludeGroupId) {
|
|
const excluded = await this.repo.findOne({
|
|
where: { id: options.excludeBookingId },
|
|
select: ['id', 'bookingGroupId'],
|
|
});
|
|
excludeGroupId = excluded?.bookingGroupId ?? null;
|
|
}
|
|
|
|
const qb = this.repo
|
|
.createQueryBuilder('b')
|
|
.leftJoinAndSelect('b.businessService', 'bs')
|
|
.leftJoinAndSelect('bs.serviceTemplate', 'tpl')
|
|
.where('b.businessId = :businessId', { businessId })
|
|
.andWhere('b.status IN (:...statuses)', {
|
|
statuses: [
|
|
BookingStatus.PENDING,
|
|
BookingStatus.AWAITING_PAYMENT,
|
|
BookingStatus.CONFIRMED,
|
|
],
|
|
})
|
|
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
|
|
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
|
|
|
if (options?.excludeBookingId && !excludeGroupId) {
|
|
qb.andWhere('b.id != :excludeBookingId', {
|
|
excludeBookingId: options.excludeBookingId,
|
|
});
|
|
}
|
|
if (excludeGroupId) {
|
|
qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', {
|
|
excludeGroupId,
|
|
});
|
|
}
|
|
|
|
const bookings = await qb.getMany();
|
|
const durations = await Promise.all(
|
|
bookings.map((b) => this.resolveBookingDuration(b)),
|
|
);
|
|
|
|
const groupTotals = new Map<
|
|
string,
|
|
{ startMs: number; totalDuration: number }
|
|
>();
|
|
const singles: BusyInterval[] = [];
|
|
|
|
for (const [index, booking] of bookings.entries()) {
|
|
const duration = durations[index] ?? 30;
|
|
const startMs = booking.scheduledAt.getTime();
|
|
|
|
if (booking.bookingGroupId) {
|
|
const key = `${booking.bookingGroupId}:${startMs}`;
|
|
const existing = groupTotals.get(key);
|
|
if (existing) {
|
|
existing.totalDuration += duration;
|
|
} else {
|
|
groupTotals.set(key, { startMs, totalDuration: duration });
|
|
}
|
|
} else {
|
|
singles.push({
|
|
startMs,
|
|
endMs: startMs + duration * 60 * 1000,
|
|
});
|
|
}
|
|
}
|
|
|
|
return [
|
|
...singles,
|
|
...Array.from(groupTotals.values()).map((group) => ({
|
|
startMs: group.startMs,
|
|
endMs: group.startMs + group.totalDuration * 60 * 1000,
|
|
})),
|
|
];
|
|
}
|
|
|
|
private async resolveBookingPricing(
|
|
businessService: BusinessServiceEntity,
|
|
serviceClassId: string | null,
|
|
): Promise<{ serviceClassId: string | null; durationMinutes: number }> {
|
|
const business = await this.businessRepo.findOne({
|
|
where: { id: businessService.businessId },
|
|
});
|
|
const activeClassCount = await this.serviceClassRepo.count({
|
|
where: { businessId: businessService.businessId, isActive: true },
|
|
});
|
|
|
|
const usesClasses =
|
|
business?.serviceClassesEnabled === true && activeClassCount > 0;
|
|
|
|
if (!usesClasses) {
|
|
if (serviceClassId) {
|
|
throw new BadRequestException(
|
|
'This business does not use service classes',
|
|
);
|
|
}
|
|
return {
|
|
serviceClassId: null,
|
|
durationMinutes: resolveServiceDurationMinutes(
|
|
businessService.durationMinutes,
|
|
businessService.serviceTemplate?.defaultDurationMinutes,
|
|
),
|
|
};
|
|
}
|
|
|
|
if (!serviceClassId) {
|
|
throw new BadRequestException('serviceClassId is required for this business');
|
|
}
|
|
|
|
const serviceClass = await this.serviceClassRepo.findOne({
|
|
where: {
|
|
id: serviceClassId,
|
|
businessId: businessService.businessId,
|
|
isActive: true,
|
|
},
|
|
});
|
|
if (!serviceClass) {
|
|
throw new BadRequestException('Service class not found or inactive');
|
|
}
|
|
|
|
const classPrice = await this.classPriceRepo.findOne({
|
|
where: {
|
|
businessServiceId: businessService.id,
|
|
classId: serviceClassId,
|
|
isActive: true,
|
|
},
|
|
});
|
|
if (!classPrice) {
|
|
throw new BadRequestException(
|
|
'This service is not available for the selected class',
|
|
);
|
|
}
|
|
|
|
return {
|
|
serviceClassId,
|
|
durationMinutes: resolveServiceDurationMinutes(
|
|
classPrice.durationMinutes ?? businessService.durationMinutes,
|
|
businessService.serviceTemplate?.defaultDurationMinutes,
|
|
),
|
|
};
|
|
}
|
|
|
|
private async resolveGroupDuration(booking: BookingEntity): Promise<number> {
|
|
if (!booking.bookingGroupId) {
|
|
return this.resolveBookingDuration(booking);
|
|
}
|
|
|
|
const siblings = await this.repo.find({
|
|
where: {
|
|
bookingGroupId: booking.bookingGroupId,
|
|
status: In([
|
|
BookingStatus.PENDING,
|
|
BookingStatus.AWAITING_PAYMENT,
|
|
BookingStatus.CONFIRMED,
|
|
]),
|
|
},
|
|
relations: { businessService: { serviceTemplate: true } },
|
|
});
|
|
|
|
const rows = siblings.length
|
|
? siblings
|
|
: [
|
|
{
|
|
...booking,
|
|
businessService:
|
|
booking.businessService ??
|
|
(await this.businessServiceRepo.findOne({
|
|
where: { id: booking.businessServiceId },
|
|
relations: { serviceTemplate: true },
|
|
})),
|
|
} as BookingEntity,
|
|
];
|
|
|
|
let total = 0;
|
|
for (const row of rows) {
|
|
total += await this.resolveBookingDuration(row);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private async resolveBookingDuration(booking: BookingEntity): Promise<number> {
|
|
if (!booking.businessService) {
|
|
const businessService = await this.businessServiceRepo.findOne({
|
|
where: { id: booking.businessServiceId },
|
|
relations: { serviceTemplate: true },
|
|
});
|
|
if (!businessService) {
|
|
return 30;
|
|
}
|
|
booking.businessService = businessService;
|
|
}
|
|
|
|
if (booking.serviceClassId) {
|
|
const classPrice = await this.classPriceRepo.findOne({
|
|
where: {
|
|
businessServiceId: booking.businessServiceId,
|
|
classId: booking.serviceClassId,
|
|
},
|
|
});
|
|
return resolveServiceDurationMinutes(
|
|
classPrice?.durationMinutes ?? booking.businessService.durationMinutes,
|
|
booking.businessService.serviceTemplate?.defaultDurationMinutes,
|
|
);
|
|
}
|
|
|
|
return resolveServiceDurationMinutes(
|
|
booking.businessService.durationMinutes,
|
|
booking.businessService.serviceTemplate?.defaultDurationMinutes,
|
|
);
|
|
}
|
|
|
|
private async assertSchedulableSlot(params: {
|
|
businessId: string;
|
|
scheduledAt: Date;
|
|
durationMinutes: number;
|
|
excludeBookingId?: string;
|
|
excludeBookingGroupId?: string;
|
|
}): Promise<void> {
|
|
const {
|
|
businessId,
|
|
scheduledAt,
|
|
durationMinutes,
|
|
excludeBookingId,
|
|
excludeBookingGroupId,
|
|
} = params;
|
|
|
|
this.assertLeadTime(scheduledAt);
|
|
|
|
const dateYmd = tehranYmd(scheduledAt);
|
|
const windows = await this.loadAvailabilityWindows(businessId);
|
|
if (!windows.length) {
|
|
throw new BadRequestException(
|
|
'Online booking hours are not configured for this business',
|
|
);
|
|
}
|
|
|
|
const { concurrentCapacity, slotStepMinutes } =
|
|
await this.getBusinessBookingSettings(businessId);
|
|
const slots = generateAvailableSlotStarts({
|
|
dateYmd,
|
|
durationMinutes,
|
|
leadMinutes: this.minLeadMinutes,
|
|
slotStepMinutes,
|
|
windows,
|
|
busy: await this.loadBusyIntervals(businessId, dateYmd, {
|
|
excludeBookingId,
|
|
excludeBookingGroupId,
|
|
}),
|
|
concurrentCapacity,
|
|
});
|
|
|
|
const scheduledMs = scheduledAt.getTime();
|
|
const allowed = slots.some((s) => new Date(s).getTime() === scheduledMs);
|
|
if (!allowed) {
|
|
throw new BadRequestException(
|
|
'Selected time is not available for this service',
|
|
);
|
|
}
|
|
}
|
|
|
|
private parseScheduledAt(raw: string): Date {
|
|
const d = new Date(raw);
|
|
if (Number.isNaN(d.getTime())) {
|
|
throw new BadRequestException('scheduledAt must be a valid ISO datetime');
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private assertLeadTime(scheduledAt: Date): void {
|
|
const minMs = this.minLeadMinutes * 60 * 1000;
|
|
const earliest = Date.now() + minMs;
|
|
if (scheduledAt.getTime() < earliest) {
|
|
throw new BadRequestException(
|
|
`scheduledAt must be at least ${this.minLeadMinutes} minute(s) after the current time`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private assertBookingIsMutable(row: BookingEntity): void {
|
|
if (
|
|
row.status === BookingStatus.CANCELLED ||
|
|
row.status === BookingStatus.COMPLETED
|
|
) {
|
|
throw new BadRequestException(
|
|
'Cancelled or completed bookings cannot be edited',
|
|
);
|
|
}
|
|
}
|
|
|
|
private applyStatusTransition(
|
|
row: BookingEntity,
|
|
nextStatus: BookingStatus,
|
|
actor?: AuthUser,
|
|
): void {
|
|
if (row.status === nextStatus) {
|
|
return;
|
|
}
|
|
|
|
// همه قوانین تغییر وضعیت رزرو در همین متد نگه داشته میشود تا مسیرهای مختلف
|
|
// (PATCH، confirm، complete، cancel) رفتار یکسان داشته باشند.
|
|
if (
|
|
row.status === BookingStatus.CANCELLED ||
|
|
row.status === BookingStatus.COMPLETED
|
|
) {
|
|
throw new BadRequestException(
|
|
'Cancelled or completed bookings cannot change status',
|
|
);
|
|
}
|
|
|
|
if (nextStatus === BookingStatus.CANCELLED) {
|
|
// مشتری فقط رزرو خودش را لغو میکند؛ صاحب کسبوکار فقط رزرو کسبوکار خودش را.
|
|
this.assertCanCancelBooking(row, actor);
|
|
row.status = BookingStatus.CANCELLED;
|
|
return;
|
|
}
|
|
|
|
if (actor?.role === UserRole.CUSTOMER) {
|
|
throw new ForbiddenException('Customers can only cancel bookings');
|
|
}
|
|
|
|
if (nextStatus === BookingStatus.CONFIRMED) {
|
|
if (row.status !== BookingStatus.AWAITING_PAYMENT) {
|
|
throw new BadRequestException(
|
|
'Only bookings awaiting payment can be confirmed',
|
|
);
|
|
}
|
|
row.status = BookingStatus.CONFIRMED;
|
|
return;
|
|
}
|
|
|
|
if (nextStatus === BookingStatus.AWAITING_PAYMENT) {
|
|
if (row.status !== BookingStatus.PENDING) {
|
|
throw new BadRequestException('Only pending bookings can be approved');
|
|
}
|
|
row.status = BookingStatus.AWAITING_PAYMENT;
|
|
return;
|
|
}
|
|
|
|
if (nextStatus === BookingStatus.COMPLETED) {
|
|
if (row.status !== BookingStatus.CONFIRMED) {
|
|
throw new BadRequestException('Only confirmed bookings can be completed');
|
|
}
|
|
row.status = BookingStatus.COMPLETED;
|
|
return;
|
|
}
|
|
|
|
throw new BadRequestException('Unsupported booking status transition');
|
|
}
|
|
|
|
private assertCanViewBooking(row: BookingEntity, actor?: AuthUser): void {
|
|
if (!actor || actor.role === UserRole.ADMIN) {
|
|
return;
|
|
}
|
|
|
|
if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
actor.role === UserRole.BUSINESS_OWNER &&
|
|
(row.business?.ownerId === actor.id || row.customerId === actor.id)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException('You cannot access this booking');
|
|
}
|
|
|
|
private assertCanCancelBooking(row: BookingEntity, actor?: AuthUser): void {
|
|
if (!actor || actor.role === UserRole.ADMIN) {
|
|
return;
|
|
}
|
|
|
|
if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
actor.role === UserRole.BUSINESS_OWNER &&
|
|
(row.business?.ownerId === actor.id || row.customerId === actor.id)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException('You cannot cancel this booking');
|
|
}
|
|
|
|
private assertCanManageBooking(row: BookingEntity, actor?: AuthUser): void {
|
|
if (!actor || actor.role === UserRole.ADMIN) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
actor.role === UserRole.BUSINESS_OWNER &&
|
|
(row.business?.ownerId === actor.id || row.customerId === actor.id)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException('You can only manage your own business bookings');
|
|
}
|
|
|
|
private async approveForPayment(
|
|
id: string,
|
|
actor?: AuthUser,
|
|
options?: { skipNotify?: boolean },
|
|
): Promise<BookingEntity> {
|
|
return this.approveForPaymentGroup(id, actor, options);
|
|
}
|
|
|
|
private async approveForPaymentGroup(
|
|
id: string,
|
|
actor?: AuthUser,
|
|
options?: { skipNotify?: boolean },
|
|
): Promise<BookingEntity> {
|
|
const row = await this.findOne(id, actor);
|
|
if (actor?.role === UserRole.BUSINESS_OWNER || actor?.role === UserRole.ADMIN) {
|
|
this.assertCanManageBooking(row, actor);
|
|
}
|
|
|
|
const group = await this.loadPaymentGroup(row);
|
|
const pending = group.filter((item) => item.status === BookingStatus.PENDING);
|
|
if (!pending.length) {
|
|
throw new BadRequestException('Only pending bookings can be approved');
|
|
}
|
|
|
|
const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, '');
|
|
const amountPerBooking = await Promise.all(
|
|
group.map(async (item) => ({
|
|
id: item.id,
|
|
amount: await this.resolveBookingPriceToman(item),
|
|
})),
|
|
);
|
|
const amountById = new Map(amountPerBooking.map((item) => [item.id, item.amount]));
|
|
|
|
for (const item of group) {
|
|
if (item.status !== BookingStatus.PENDING) continue;
|
|
item.status = BookingStatus.AWAITING_PAYMENT;
|
|
item.paymentToken = paymentToken;
|
|
item.paymentAmount = String(amountById.get(item.id) ?? 0);
|
|
}
|
|
await this.repo.save(group);
|
|
|
|
if (!options?.skipNotify) {
|
|
await this.notifyPaymentReady(group);
|
|
}
|
|
|
|
return this.findOne(id, actor);
|
|
}
|
|
|
|
private async loadPaymentGroup(row: BookingEntity): Promise<BookingEntity[]> {
|
|
if (!row.bookingGroupId) {
|
|
const fresh = await this.findOne(row.id);
|
|
return [fresh];
|
|
}
|
|
const siblings = await this.repo.find({
|
|
where: { bookingGroupId: row.bookingGroupId },
|
|
relations: {
|
|
customer: true,
|
|
business: true,
|
|
businessService: { serviceTemplate: true },
|
|
serviceClass: true,
|
|
},
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
return siblings.length ? siblings : [row];
|
|
}
|
|
|
|
private async resolveGroupAmountToman(group: BookingEntity[]): Promise<number> {
|
|
let total = 0;
|
|
for (const row of group) {
|
|
if (row.paymentAmount) {
|
|
total += Number.parseInt(row.paymentAmount, 10);
|
|
} else {
|
|
total += await this.resolveBookingPriceToman(row);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private async resolveBookingPriceToman(booking: BookingEntity): Promise<number> {
|
|
let businessService = booking.businessService;
|
|
if (!businessService) {
|
|
const loaded = await this.businessServiceRepo.findOne({
|
|
where: { id: booking.businessServiceId },
|
|
relations: { serviceTemplate: true },
|
|
});
|
|
if (!loaded) {
|
|
return 0;
|
|
}
|
|
businessService = loaded;
|
|
booking.businessService = loaded;
|
|
}
|
|
|
|
if (booking.serviceClassId) {
|
|
const classPrice = await this.classPriceRepo.findOne({
|
|
where: {
|
|
businessServiceId: booking.businessServiceId,
|
|
classId: booking.serviceClassId,
|
|
isActive: true,
|
|
},
|
|
});
|
|
if (classPrice) {
|
|
return Math.round(Number.parseFloat(classPrice.price));
|
|
}
|
|
}
|
|
|
|
return Math.round(Number.parseFloat(businessService.price));
|
|
}
|
|
|
|
private buildPaymentPageUrl(token: string): string {
|
|
return `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
private async completePaymentGroup(
|
|
group: BookingEntity[],
|
|
authority: string,
|
|
): Promise<void> {
|
|
for (const row of group) {
|
|
row.status = BookingStatus.CONFIRMED;
|
|
row.paymentAuthority = authority;
|
|
}
|
|
await this.repo.save(group);
|
|
}
|
|
|
|
private async notifyPaymentReady(group: BookingEntity[]): Promise<void> {
|
|
const leader = group[0];
|
|
if (!leader?.paymentToken || !leader.customer?.mobile) {
|
|
return;
|
|
}
|
|
|
|
const businessTitle = leader.business?.title ?? 'کسبوکار';
|
|
const payUrl = this.buildPaymentPageUrl(leader.paymentToken);
|
|
const amount = await this.resolveGroupAmountToman(group);
|
|
|
|
await this.sms.sendSms(
|
|
leader.customer.mobile,
|
|
`رزرو شما در «${businessTitle}» تأیید شد. مبلغ ${amount.toLocaleString('fa-IR')} تومان — پرداخت: ${payUrl}`,
|
|
);
|
|
}
|
|
}
|