2026-05-09 17:29:22 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
2026-05-11 12:40:42 +03:30
|
|
|
ForbiddenException,
|
2026-05-09 17:29:22 +03:30
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
|
|
|
import { Repository } from 'typeorm';
|
|
|
|
|
import { BookingEntity, BookingStatus } from '../../entities/booking.entity';
|
|
|
|
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
|
|
|
|
import { BusinessEntity } from '../../entities/business.entity';
|
2026-05-11 12:40:42 +03:30
|
|
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
|
|
|
|
import { AuthUser } from '../auth/auth.types';
|
2026-05-09 17:29:22 +03:30
|
|
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
|
|
|
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
|
|
|
|
|
|
|
|
|
type FindAllFilters = {
|
|
|
|
|
customerId?: string;
|
|
|
|
|
businessId?: string;
|
|
|
|
|
status?: BookingStatus;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class BookingsService {
|
|
|
|
|
private readonly minLeadMinutes: number;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly config: ConfigService,
|
|
|
|
|
@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>,
|
|
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
async findAll(
|
|
|
|
|
filters: FindAllFilters,
|
|
|
|
|
actor?: AuthUser,
|
|
|
|
|
): Promise<BookingEntity[]> {
|
2026-05-09 17:29:22 +03:30
|
|
|
const qb = this.repo
|
|
|
|
|
.createQueryBuilder('b')
|
|
|
|
|
.leftJoinAndSelect('b.customer', 'customer')
|
|
|
|
|
.leftJoinAndSelect('b.business', 'business')
|
|
|
|
|
.leftJoinAndSelect('b.businessService', 'businessService')
|
|
|
|
|
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
|
|
|
|
|
.orderBy('b.scheduledAt', 'ASC');
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
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', {
|
|
|
|
|
actorOwnerId: actor.id,
|
|
|
|
|
});
|
|
|
|
|
} else if (filters.customerId) {
|
2026-05-09 17:29:22 +03:30
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
async findOne(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
2026-05-09 17:29:22 +03:30
|
|
|
const row = await this.repo.findOne({
|
|
|
|
|
where: { id },
|
|
|
|
|
relations: {
|
|
|
|
|
customer: true,
|
|
|
|
|
business: true,
|
|
|
|
|
businessService: { serviceTemplate: true },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!row) {
|
|
|
|
|
throw new NotFoundException('Booking not found');
|
|
|
|
|
}
|
2026-05-11 12:40:42 +03:30
|
|
|
this.assertCanViewBooking(row, actor);
|
2026-05-09 17:29:22 +03:30
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
async create(dto: CreateBookingDto, actor?: AuthUser): Promise<BookingEntity> {
|
|
|
|
|
if (
|
|
|
|
|
actor?.role === UserRole.CUSTOMER &&
|
|
|
|
|
dto.customerId !== actor.id
|
|
|
|
|
) {
|
|
|
|
|
throw new ForbiddenException('Customers can only book for themselves');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 17:29:22 +03:30
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
this.assertLeadTime(scheduledAt);
|
|
|
|
|
|
|
|
|
|
const entity = this.repo.create({
|
|
|
|
|
customerId: dto.customerId,
|
|
|
|
|
businessId: dto.businessId,
|
|
|
|
|
businessServiceId: dto.businessServiceId,
|
|
|
|
|
scheduledAt,
|
|
|
|
|
note: dto.note ?? null,
|
|
|
|
|
status: BookingStatus.PENDING,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.repo.save(entity);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
async update(
|
|
|
|
|
id: string,
|
|
|
|
|
dto: UpdateBookingDto,
|
|
|
|
|
actor?: AuthUser,
|
|
|
|
|
): Promise<BookingEntity> {
|
2026-05-09 17:29:22 +03:30
|
|
|
const row = await this.findOne(id);
|
2026-05-11 12:40:42 +03:30
|
|
|
this.assertCanManageBooking(row, actor);
|
2026-05-12 16:48:23 +03:30
|
|
|
this.assertBookingIsMutable(row);
|
2026-05-09 17:29:22 +03:30
|
|
|
|
|
|
|
|
if (dto.scheduledAt !== undefined) {
|
|
|
|
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
|
|
|
|
this.assertLeadTime(scheduledAt);
|
|
|
|
|
row.scheduledAt = scheduledAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dto.status !== undefined) {
|
2026-05-12 16:48:23 +03:30
|
|
|
this.applyStatusTransition(row, dto.status, actor);
|
2026-05-09 17:29:22 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dto.note !== undefined) {
|
|
|
|
|
row.note = dto.note;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.repo.save(row);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:48:23 +03:30
|
|
|
async confirm(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
|
|
|
|
const row = await this.findOne(id);
|
|
|
|
|
this.assertCanManageBooking(row, actor);
|
|
|
|
|
this.applyStatusTransition(row, BookingStatus.CONFIRMED, actor);
|
|
|
|
|
return this.repo.save(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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): Promise<BookingEntity> {
|
|
|
|
|
const row = await this.findOne(id);
|
|
|
|
|
this.assertCanCancelBooking(row, actor);
|
|
|
|
|
this.applyStatusTransition(row, BookingStatus.CANCELLED, actor);
|
|
|
|
|
return this.repo.save(row);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 17:29:22 +03:30
|
|
|
async remove(id: string): Promise<void> {
|
|
|
|
|
const result = await this.repo.delete(id);
|
|
|
|
|
if (!result.affected) {
|
|
|
|
|
throw new NotFoundException('Booking not found');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 12:40:42 +03:30
|
|
|
|
2026-05-12 16:48:23 +03:30
|
|
|
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.PENDING) {
|
|
|
|
|
throw new BadRequestException('Only pending bookings can be confirmed');
|
|
|
|
|
}
|
|
|
|
|
row.status = BookingStatus.CONFIRMED;
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
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
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ForbiddenException('You cannot access this booking');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:48:23 +03:30
|
|
|
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
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ForbiddenException('You cannot cancel this booking');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:40:42 +03:30
|
|
|
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
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ForbiddenException('You can only manage your own business bookings');
|
|
|
|
|
}
|
2026-05-09 17:29:22 +03:30
|
|
|
}
|