Files
serverMontime/src/modules/bookings/bookings.service.ts
T

803 lines
24 KiB
TypeScript
Raw Normal View History

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';
2026-05-31 21:36:19 +03:30
import { randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
2026-05-25 19:12:32 +03:30
import {
generateAvailableSlotStarts,
parseGregorianYmd,
resolveServiceDurationMinutes,
tehranYmd,
type AvailabilityWindow,
type BusyInterval,
} from '../../common/utils/booking-schedule.util';
2026-05-09 17:29:22 +03:30
import { BookingEntity, BookingStatus } from '../../entities/booking.entity';
2026-05-25 19:12:32 +03:30
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
2026-05-31 21:36:19 +03:30
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
2026-05-09 17:29:22 +03:30
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';
2026-05-31 21:36:19 +03:30
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
2026-05-09 17:29:22 +03:30
import { UpdateBookingDto } from './dto/update-booking.dto';
type FindAllFilters = {
customerId?: string;
businessId?: string;
status?: BookingStatus;
2026-05-31 16:39:55 +03:30
scope?: 'mine';
2026-05-09 17:29:22 +03:30
};
@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>,
2026-05-25 19:12:32 +03:30
@InjectRepository(BusinessAvailabilitySlotEntity)
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
2026-05-31 21:36:19 +03:30
@InjectRepository(BusinessServiceClassEntity)
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessServiceClassPriceEntity)
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
2026-05-09 17:29:22 +03:30
) {
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')
2026-05-31 21:36:19 +03:30
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
2026-05-09 17:29:22 +03:30
.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) {
2026-05-31 16:39:55 +03:30
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,
});
}
2026-05-11 12:40:42 +03:30
} 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 },
2026-05-31 21:36:19 +03:30
serviceClass: true,
2026-05-09 17:29:22 +03:30
},
});
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> {
2026-05-31 16:39:55 +03:30
if (actor && dto.customerId !== actor.id) {
throw new ForbiddenException('You can only book for yourself');
2026-05-11 12:40:42 +03:30
}
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 },
2026-05-25 19:12:32 +03:30
relations: { business: true, serviceTemplate: true },
2026-05-09 17:29:22 +03:30
});
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);
2026-05-31 21:36:19 +03:30
const pricing = await this.resolveBookingPricing(
businessService,
dto.serviceClassId ?? null,
2026-05-25 19:12:32 +03:30
);
await this.assertSchedulableSlot({
businessId: dto.businessId,
scheduledAt,
2026-05-31 21:36:19 +03:30
durationMinutes: pricing.durationMinutes,
2026-05-25 19:12:32 +03:30
});
2026-05-09 17:29:22 +03:30
const entity = this.repo.create({
customerId: dto.customerId,
businessId: dto.businessId,
businessServiceId: dto.businessServiceId,
2026-05-31 21:36:19 +03:30
serviceClassId: pricing.serviceClassId,
2026-05-09 17:29:22 +03:30
scheduledAt,
note: dto.note ?? null,
status: BookingStatus.PENDING,
});
return this.repo.save(entity);
}
2026-05-31 21:36:19 +03:30
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,
}),
);
return this.repo.save(entities);
}
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);
2026-05-31 21:36:19 +03:30
const durationMinutes = await this.resolveGroupDuration(row);
2026-05-25 19:12:32 +03:30
await this.assertSchedulableSlot({
businessId: row.businessId,
scheduledAt,
durationMinutes,
excludeBookingId: row.id,
2026-05-31 21:36:19 +03:30
excludeBookingGroupId: row.bookingGroupId ?? undefined,
2026-05-25 19:12:32 +03:30
});
2026-05-09 17:29:22 +03:30
row.scheduledAt = scheduledAt;
2026-05-31 21:36:19 +03:30
if (row.bookingGroupId) {
await this.repo.update(
{ bookingGroupId: row.bookingGroupId },
{ scheduledAt },
);
}
2026-05-09 17:29:22 +03:30
}
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');
}
}
2026-05-25 19:12:32 +03:30
async getAvailableSlots(
businessId: string,
dateYmd: string,
2026-05-31 21:36:19 +03:30
options: {
businessServiceId?: string;
businessServiceIds?: string[];
serviceClassId?: string;
},
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
2026-05-25 19:12:32 +03:30
parseGregorianYmd(dateYmd);
const business = await this.businessRepo.findOne({
where: { id: businessId, isActive: true },
});
if (!business) {
throw new NotFoundException('Business not found or inactive');
}
2026-05-31 21:36:19 +03:30
const serviceIds =
options.businessServiceIds?.length
? options.businessServiceIds
: options.businessServiceId
? [options.businessServiceId]
: [];
if (!serviceIds.length) {
throw new BadRequestException('At least one business service is required');
2026-05-25 19:12:32 +03:30
}
2026-05-31 21:36:19 +03:30
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;
}
2026-05-25 19:12:32 +03:30
const windows = await this.loadAvailabilityWindows(businessId);
const busy = await this.loadBusyIntervals(businessId, dateYmd);
const slots = generateAvailableSlotStarts({
dateYmd,
2026-05-31 21:36:19 +03:30
durationMinutes: totalDurationMinutes,
2026-05-25 19:12:32 +03:30
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
busy,
});
2026-05-31 21:36:19 +03:30
return { date: dateYmd, slots, totalDurationMinutes };
2026-05-25 19:12:32 +03: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,
2026-05-31 21:36:19 +03:30
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
2026-05-25 19:12:32 +03:30
): Promise<BusyInterval[]> {
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`);
2026-05-31 21:36:19 +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;
}
2026-05-25 19:12:32 +03:30
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.CONFIRMED],
})
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
2026-05-31 21:36:19 +03:30
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,
});
2026-05-25 19:12:32 +03:30
}
const bookings = await qb.getMany();
2026-05-31 21:36:19 +03:30
const durations = await Promise.all(
bookings.map((b) => this.resolveBookingDuration(b)),
);
2026-05-25 19:12:32 +03:30
2026-05-31 21:36:19 +03:30
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 },
2026-05-25 19:12:32 +03:30
});
2026-05-31 21:36:19 +03:30
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.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,
);
2026-05-25 19:12:32 +03:30
}
private async assertSchedulableSlot(params: {
businessId: string;
scheduledAt: Date;
durationMinutes: number;
excludeBookingId?: string;
2026-05-31 21:36:19 +03:30
excludeBookingGroupId?: string;
2026-05-25 19:12:32 +03:30
}): Promise<void> {
2026-05-31 21:36:19 +03:30
const {
businessId,
scheduledAt,
durationMinutes,
excludeBookingId,
excludeBookingGroupId,
} = params;
2026-05-25 19:12:32 +03:30
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 slots = generateAvailableSlotStarts({
dateYmd,
durationMinutes,
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
2026-05-31 21:36:19 +03:30
busy: await this.loadBusyIntervals(businessId, dateYmd, {
2026-05-25 19:12:32 +03:30
excludeBookingId,
2026-05-31 21:36:19 +03:30
excludeBookingGroupId,
}),
2026-05-25 19:12:32 +03:30
});
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',
);
}
}
2026-05-09 17:29:22 +03:30
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 &&
2026-05-31 16:39:55 +03:30
(row.business?.ownerId === actor.id || row.customerId === actor.id)
2026-05-11 12:40:42 +03:30
) {
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 &&
2026-05-31 16:39:55 +03:30
(row.business?.ownerId === actor.id || row.customerId === actor.id)
2026-05-12 16:48:23 +03:30
) {
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 &&
2026-05-31 16:39:55 +03:30
(row.business?.ownerId === actor.id || row.customerId === actor.id)
2026-05-11 12:40:42 +03:30
) {
return;
}
throw new ForbiddenException('You can only manage your own business bookings');
}
2026-05-09 17:29:22 +03:30
}