diff --git a/.env.example b/.env.example index 49853f0..c16b76c 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,6 @@ SMS_SENDER= SMS_TRANSPORT=soap # SMS_DOMAIN=0098 # only for link transport # SMS_TIMEOUT_MS=20000 + +# Minimum minutes before a booking can be scheduled (default 60) +BOOKING_MIN_LEAD_MINUTES=60 diff --git a/src/common/utils/booking-schedule.util.ts b/src/common/utils/booking-schedule.util.ts new file mode 100644 index 0000000..eb1a880 --- /dev/null +++ b/src/common/utils/booking-schedule.util.ts @@ -0,0 +1,176 @@ +import { BadRequestException } from '@nestjs/common'; + +/** Iranian week: شنبه=0 … جمعه=6 */ +const TEHRAN_WEEKDAY: Record = { + Sat: 0, + Sun: 1, + Mon: 2, + Tue: 3, + Wed: 4, + Thu: 5, + Fri: 6, +}; + +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export type AvailabilityWindow = { + dayOfWeek: number; + startMinute: number; + endMinute: number; +}; + +export type BusyInterval = { + startMs: number; + endMs: number; +}; + +export function parseHmToMinute(value: string): number { + const match = TIME_RE.exec(String(value).trim()); + if (!match) { + throw new BadRequestException('Time must be HH:mm (24h)'); + } + return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10); +} + +export function 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')}`; +} + +export function parseGregorianYmd(ymd: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) { + throw new BadRequestException('date must be YYYY-MM-DD'); + } +} + +/** Tehran local datetime as UTC Date. */ +export function tehranDateTime(ymd: string, hour: number, minute: number): Date { + parseGregorianYmd(ymd); + const hh = String(hour).padStart(2, '0'); + const mm = String(minute).padStart(2, '0'); + return new Date(`${ymd}T${hh}:${mm}:00+03:30`); +} + +export function tehranYmd(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const y = parts.find((p) => p.type === 'year')?.value; + const m = parts.find((p) => p.type === 'month')?.value; + const d = parts.find((p) => p.type === 'day')?.value; + return `${y}-${m}-${d}`; +} + +export function tehranDayOfWeek(date: Date): number { + const wd = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Tehran', + weekday: 'short', + }).format(date); + const mapped = TEHRAN_WEEKDAY[wd]; + if (mapped === undefined) { + throw new BadRequestException('Could not resolve weekday'); + } + return mapped; +} + +export function isSameTehranDay(a: Date, b: Date): boolean { + return tehranYmd(a) === tehranYmd(b); +} + +export function intervalsOverlap( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): boolean { + return aStart < bEnd && bStart < aEnd; +} + +export function resolveServiceDurationMinutes( + durationMinutes: number | null | undefined, + templateDefault?: number | null, +): number { + if (durationMinutes != null && durationMinutes > 0) { + return durationMinutes; + } + if (templateDefault != null && templateDefault > 0) { + return templateDefault; + } + return 30; +} + +export function generateAvailableSlotStarts(params: { + dateYmd: string; + durationMinutes: number; + leadMinutes: number; + slotStepMinutes: number; + windows: AvailabilityWindow[]; + busy: BusyInterval[]; + now?: Date; +}): string[] { + const { + dateYmd, + durationMinutes, + leadMinutes, + slotStepMinutes, + windows, + busy, + now = new Date(), + } = params; + + const dayAnchor = tehranDateTime(dateYmd, 12, 0); + const dow = tehranDayOfWeek(dayAnchor); + const dayWindows = windows.filter((w) => w.dayOfWeek === dow); + if (!dayWindows.length) { + return []; + } + + const earliestMs = isSameTehranDay(dayAnchor, now) + ? now.getTime() + leadMinutes * 60 * 1000 + : tehranDateTime(dateYmd, 0, 0).getTime(); + + const slots: string[] = []; + const step = Math.max(5, slotStepMinutes); + + for (const window of dayWindows) { + for ( + let startMin = window.startMinute; + startMin + durationMinutes <= window.endMinute; + startMin += step + ) { + const hour = Math.floor(startMin / 60); + const minute = startMin % 60; + const startDate = tehranDateTime(dateYmd, hour, minute); + const startMs = startDate.getTime(); + const endMs = startMs + durationMinutes * 60 * 1000; + + if (startMs < earliestMs) { + continue; + } + + const windowEndMs = tehranDateTime( + dateYmd, + Math.floor(window.endMinute / 60), + window.endMinute % 60, + ).getTime(); + if (endMs > windowEndMs) { + continue; + } + + const blocked = busy.some((b) => + intervalsOverlap(startMs, endMs, b.startMs, b.endMs), + ); + if (blocked) { + continue; + } + + slots.push(startDate.toISOString()); + } + } + + return slots; +} diff --git a/src/common/utils/business-cascade.util.ts b/src/common/utils/business-cascade.util.ts index 93c034e..0d8a9ba 100644 --- a/src/common/utils/business-cascade.util.ts +++ b/src/common/utils/business-cascade.util.ts @@ -6,6 +6,7 @@ import { BusinessAttributeValueEntity } from '../../entities/business-attribute- import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessImageEntity } from '../../entities/business-image.entity'; +import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; @@ -16,6 +17,7 @@ export async function deleteBusinessCascade( onBeforeDeleteBusiness?: (businessId: string) => Promise, ): Promise { await manager.delete(BookingEntity, { businessId }); + await manager.delete(BusinessAvailabilitySlotEntity, { businessId }); await manager.delete(BusinessServiceEntity, { businessId }); await manager.delete(BusinessAttributeValueEntity, { businessId }); await manager.delete(BusinessImageEntity, { businessId }); diff --git a/src/entities/business-availability-slot.entity.ts b/src/entities/business-availability-slot.entity.ts new file mode 100644 index 0000000..d4e986f --- /dev/null +++ b/src/entities/business-availability-slot.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { BusinessEntity } from './business.entity'; + +/** dayOfWeek: 0=شنبه … 6=جمعه (Iranian week) */ +@Entity('business_availability_slots') +export class BusinessAvailabilitySlotEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BusinessEntity, (business) => business.availabilitySlots, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'businessId' }) + business!: BusinessEntity; + + @Column('uuid') + businessId!: string; + + @Column({ type: 'smallint' }) + dayOfWeek!: number; + + /** Minutes from midnight (0–1439), inclusive start */ + @Column({ type: 'int' }) + startMinute!: number; + + /** Minutes from midnight, exclusive end */ + @Column({ type: 'int' }) + endMinute!: number; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 8e568fe..0b1705c 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -12,6 +12,7 @@ import { BookingEntity } from './booking.entity'; import { BusinessAttributeValueEntity } from './business-attribute-value.entity'; import { BusinessCategoryEntity } from './business-category.entity'; import { BusinessImageEntity } from './business-image.entity'; +import { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity'; import { BusinessServiceEntity } from './business-service.entity'; import { UserEntity } from './user.entity'; @@ -77,6 +78,11 @@ export class BusinessEntity { @OneToMany(() => BookingEntity, (booking) => booking.business) bookings!: BookingEntity[]; + @OneToMany(() => BusinessAvailabilitySlotEntity, (slot) => slot.business, { + cascade: true, + }) + availabilitySlots!: BusinessAvailabilitySlotEntity[]; + @CreateDateColumn() createdAt!: Date; diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index 08d8ede..27b84da 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -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) diff --git a/src/modules/bookings/bookings.module.ts b/src/modules/bookings/bookings.module.ts index e6d8905..8bde5cf 100644 --- a/src/modules/bookings/bookings.module.ts +++ b/src/modules/bookings/bookings.module.ts @@ -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, ], diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index dfd7b46..1d36b2f 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -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, @InjectRepository(BusinessServiceEntity) private readonly businessServiceRepo: Repository, + @InjectRepository(BusinessAvailabilitySlotEntity) + private readonly availabilityRepo: Repository, ) { const raw = this.config.get('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 { + 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 { + 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 { + 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())) { diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 626fd22..efe2b62 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -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 { return this.service.findOne(id); diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index da78201..5f1a12d 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -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, ], diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index a2fc8c1..e911530 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -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, + @InjectRepository(BusinessAvailabilitySlotEntity) + private readonly availabilityRepo: Repository, @InjectRepository(BusinessCategoryEntity) private readonly categoryRepo: Repository, @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, diff --git a/src/modules/businesses/dto/set-availability.dto.ts b/src/modules/businesses/dto/set-availability.dto.ts new file mode 100644 index 0000000..6ae872f --- /dev/null +++ b/src/modules/businesses/dto/set-availability.dto.ts @@ -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[]; +}