Add time rezerv
This commit is contained in:
@@ -25,6 +25,15 @@ import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
export class BookingsController {
|
||||
constructor(private readonly service: BookingsService) {}
|
||||
|
||||
@Get('available-slots')
|
||||
getAvailableSlots(
|
||||
@Query('businessId') businessId: string,
|
||||
@Query('businessServiceId') businessServiceId: string,
|
||||
@Query('date') date: string,
|
||||
) {
|
||||
return this.service.getAvailableSlots(businessId, businessServiceId, date);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingEntity } from '../../entities/booking.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
@@ -15,6 +17,8 @@ import { BookingsService } from './bookings.service';
|
||||
UserEntity,
|
||||
BusinessEntity,
|
||||
BusinessServiceEntity,
|
||||
BusinessAvailabilitySlotEntity,
|
||||
ServiceTemplateEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
],
|
||||
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { 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 { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
@@ -35,6 +44,8 @@ export class BookingsService {
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
@InjectRepository(BusinessServiceEntity)
|
||||
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
|
||||
@InjectRepository(BusinessAvailabilitySlotEntity)
|
||||
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
||||
) {
|
||||
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
||||
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||
@@ -118,7 +129,7 @@ export class BookingsService {
|
||||
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: dto.businessServiceId },
|
||||
relations: { business: true },
|
||||
relations: { business: true, serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
throw new NotFoundException('Business service not found');
|
||||
@@ -131,7 +142,16 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||
this.assertLeadTime(scheduledAt);
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
await this.assertSchedulableSlot({
|
||||
businessId: dto.businessId,
|
||||
businessServiceId: dto.businessServiceId,
|
||||
scheduledAt,
|
||||
durationMinutes,
|
||||
});
|
||||
|
||||
const entity = this.repo.create({
|
||||
customerId: dto.customerId,
|
||||
@@ -156,7 +176,17 @@ export class BookingsService {
|
||||
|
||||
if (dto.scheduledAt !== undefined) {
|
||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||
this.assertLeadTime(scheduledAt);
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
row.businessService?.durationMinutes,
|
||||
row.businessService?.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
await this.assertSchedulableSlot({
|
||||
businessId: row.businessId,
|
||||
businessServiceId: row.businessServiceId,
|
||||
scheduledAt,
|
||||
durationMinutes,
|
||||
excludeBookingId: row.id,
|
||||
});
|
||||
row.scheduledAt = scheduledAt;
|
||||
}
|
||||
|
||||
@@ -199,6 +229,139 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableSlots(
|
||||
businessId: string,
|
||||
businessServiceId: string,
|
||||
dateYmd: string,
|
||||
): Promise<{ date: string; slots: string[] }> {
|
||||
parseGregorianYmd(dateYmd);
|
||||
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: businessId, isActive: true },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found or inactive');
|
||||
}
|
||||
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: businessServiceId, businessId, isActive: true },
|
||||
relations: { serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
throw new NotFoundException('Business service not found or inactive');
|
||||
}
|
||||
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
|
||||
const windows = await this.loadAvailabilityWindows(businessId);
|
||||
const busy = await this.loadBusyIntervals(businessId, dateYmd);
|
||||
|
||||
const slots = generateAvailableSlotStarts({
|
||||
dateYmd,
|
||||
durationMinutes,
|
||||
leadMinutes: this.minLeadMinutes,
|
||||
slotStepMinutes: 15,
|
||||
windows,
|
||||
busy,
|
||||
});
|
||||
|
||||
return { date: dateYmd, slots };
|
||||
}
|
||||
|
||||
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,
|
||||
excludeBookingId?: string,
|
||||
): Promise<BusyInterval[]> {
|
||||
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
|
||||
const dayEnd = new Date(`${dateYmd}T23:59:59+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 });
|
||||
|
||||
if (excludeBookingId) {
|
||||
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
|
||||
}
|
||||
|
||||
const bookings = await qb.getMany();
|
||||
|
||||
return bookings.map((b) => {
|
||||
const duration = resolveServiceDurationMinutes(
|
||||
b.businessService?.durationMinutes,
|
||||
b.businessService?.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
const startMs = b.scheduledAt.getTime();
|
||||
return {
|
||||
startMs,
|
||||
endMs: startMs + duration * 60 * 1000,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async assertSchedulableSlot(params: {
|
||||
businessId: string;
|
||||
businessServiceId: string;
|
||||
scheduledAt: Date;
|
||||
durationMinutes: number;
|
||||
excludeBookingId?: string;
|
||||
}): Promise<void> {
|
||||
const { businessId, scheduledAt, durationMinutes, excludeBookingId } =
|
||||
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 slots = generateAvailableSlotStarts({
|
||||
dateYmd,
|
||||
durationMinutes,
|
||||
leadMinutes: this.minLeadMinutes,
|
||||
slotStepMinutes: 15,
|
||||
windows,
|
||||
busy: await this.loadBusyIntervals(
|
||||
businessId,
|
||||
dateYmd,
|
||||
excludeBookingId,
|
||||
),
|
||||
});
|
||||
|
||||
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())) {
|
||||
|
||||
Reference in New Issue
Block a user