Add time rezerv

This commit is contained in:
2026-05-25 19:12:32 +03:30
parent 26c3e02d6f
commit 8263198d1e
12 changed files with 540 additions and 3 deletions
@@ -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)
+4
View File
@@ -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,
],
+166 -3
View File
@@ -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())) {
@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UploadedFiles,
UseGuards,
@@ -25,6 +26,7 @@ import { BusinessImagesService } from './business-images.service';
import { BusinessesService } from './businesses.service';
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
import { CreateBusinessDto } from './dto/create-business.dto';
import { SetAvailabilityDto } from './dto/set-availability.dto';
import { UpdateBusinessDto } from './dto/update-business.dto';
@Controller('businesses')
@@ -55,6 +57,22 @@ export class BusinessesController {
return this.service.findMine(user.id);
}
@Get(':id/availability')
getAvailability(@Param('id') id: string) {
return this.service.getAvailability(id);
}
@Put(':id/availability')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
setAvailability(
@Param('id') id: string,
@Body() payload: SetAvailabilityDto,
@CurrentUser() user: AuthUser,
) {
return this.service.setAvailability(id, payload, user);
}
@Get(':id')
findOne(@Param('id') id: string): Promise<BusinessEntity> {
return this.service.findOne(id);
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessImageEntity } from '../../entities/business-image.entity';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity } from '../../entities/user.entity';
import { AuthModule } from '../auth/auth.module';
@@ -16,6 +17,7 @@ import { BusinessesService } from './businesses.service';
BusinessCategoryEntity,
UserEntity,
BusinessImageEntity,
BusinessAvailabilitySlotEntity,
]),
AuthModule,
],
@@ -11,11 +11,14 @@ import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'
import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
import { BusinessImagesService } from './business-images.service';
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { parseHmToMinute } from '../../common/utils/booking-schedule.util';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity, UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
import { CreateBusinessDto } from './dto/create-business.dto';
import { SetAvailabilityDto } from './dto/set-availability.dto';
import { UpdateBusinessDto } from './dto/update-business.dto';
type FindAllFilters = {
@@ -28,6 +31,8 @@ export class BusinessesService {
constructor(
@InjectRepository(BusinessEntity)
private readonly repo: Repository<BusinessEntity>,
@InjectRepository(BusinessAvailabilitySlotEntity)
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
@InjectRepository(BusinessCategoryEntity)
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
@InjectRepository(UserEntity)
@@ -147,6 +152,52 @@ export class BusinessesService {
});
}
async getAvailability(businessId: string) {
await this.findOne(businessId);
const rows = await this.availabilityRepo.find({
where: { businessId },
order: { dayOfWeek: 'ASC', startMinute: 'ASC' },
});
return rows.map((row) => ({
id: row.id,
dayOfWeek: row.dayOfWeek,
startTime: this.minuteToHm(row.startMinute),
endTime: this.minuteToHm(row.endMinute),
}));
}
async setAvailability(
businessId: string,
dto: SetAvailabilityDto,
actor?: AuthUser,
) {
const business = await this.findOne(businessId);
this.assertCanManageAvailability(business, actor);
const entities: BusinessAvailabilitySlotEntity[] = [];
for (const slot of dto.slots) {
const startMinute = parseHmToMinute(slot.startTime);
const endMinute = parseHmToMinute(slot.endTime);
if (endMinute <= startMinute) {
throw new BadRequestException('endTime must be after startTime');
}
entities.push(
this.availabilityRepo.create({
businessId,
dayOfWeek: slot.dayOfWeek,
startMinute,
endMinute,
}),
);
}
await this.availabilityRepo.delete({ businessId });
if (entities.length) {
await this.availabilityRepo.save(entities);
}
return this.getAvailability(businessId);
}
async assignOwner(
id: string,
dto: AssignBusinessOwnerDto,
@@ -192,6 +243,27 @@ export class BusinessesService {
return normalized;
}
private minuteToHm(minute: number): string {
const h = Math.floor(minute / 60);
const m = minute % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
private assertCanManageAvailability(
business: BusinessEntity,
actor?: AuthUser,
): void {
if (!actor || actor.role === UserRole.ADMIN) {
return;
}
if (
actor.role !== UserRole.BUSINESS_OWNER ||
business.ownerId !== actor.id
) {
throw new ForbiddenException('You can only manage your own business');
}
}
private assertCanUpdateBusiness(
business: BusinessEntity,
payload: UpdateBusinessDto,
@@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsString,
Matches,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class AvailabilitySlotDto {
@ApiProperty({ description: '0=شنبه … 6=جمعه', minimum: 0, maximum: 6 })
@IsInt()
@Min(0)
@Max(6)
dayOfWeek!: number;
@ApiProperty({ example: '09:00' })
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d]$/)
startTime!: string;
@ApiProperty({ example: '17:00' })
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d]$/)
endTime!: string;
}
export class SetAvailabilityDto {
@ApiProperty({ type: [AvailabilitySlotDto] })
@IsArray()
@ArrayMaxSize(70)
@ValidateNested({ each: true })
@Type(() => AvailabilitySlotDto)
slots!: AvailabilitySlotDto[];
}