Add time rezerv
This commit is contained in:
@@ -35,3 +35,6 @@ SMS_SENDER=
|
|||||||
SMS_TRANSPORT=soap
|
SMS_TRANSPORT=soap
|
||||||
# SMS_DOMAIN=0098 # only for link transport
|
# SMS_DOMAIN=0098 # only for link transport
|
||||||
# SMS_TIMEOUT_MS=20000
|
# SMS_TIMEOUT_MS=20000
|
||||||
|
|
||||||
|
# Minimum minutes before a booking can be scheduled (default 60)
|
||||||
|
BOOKING_MIN_LEAD_MINUTES=60
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** Iranian week: شنبه=0 … جمعه=6 */
|
||||||
|
const TEHRAN_WEEKDAY: Record<string, number> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { BusinessAttributeValueEntity } from '../../entities/business-attribute-
|
|||||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||||
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export async function deleteBusinessCascade(
|
|||||||
onBeforeDeleteBusiness?: (businessId: string) => Promise<void>,
|
onBeforeDeleteBusiness?: (businessId: string) => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await manager.delete(BookingEntity, { businessId });
|
await manager.delete(BookingEntity, { businessId });
|
||||||
|
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
|
||||||
await manager.delete(BusinessServiceEntity, { businessId });
|
await manager.delete(BusinessServiceEntity, { businessId });
|
||||||
await manager.delete(BusinessAttributeValueEntity, { businessId });
|
await manager.delete(BusinessAttributeValueEntity, { businessId });
|
||||||
await manager.delete(BusinessImageEntity, { businessId });
|
await manager.delete(BusinessImageEntity, { businessId });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { BookingEntity } from './booking.entity';
|
|||||||
import { BusinessAttributeValueEntity } from './business-attribute-value.entity';
|
import { BusinessAttributeValueEntity } from './business-attribute-value.entity';
|
||||||
import { BusinessCategoryEntity } from './business-category.entity';
|
import { BusinessCategoryEntity } from './business-category.entity';
|
||||||
import { BusinessImageEntity } from './business-image.entity';
|
import { BusinessImageEntity } from './business-image.entity';
|
||||||
|
import { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity';
|
||||||
import { BusinessServiceEntity } from './business-service.entity';
|
import { BusinessServiceEntity } from './business-service.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@@ -77,6 +78,11 @@ export class BusinessEntity {
|
|||||||
@OneToMany(() => BookingEntity, (booking) => booking.business)
|
@OneToMany(() => BookingEntity, (booking) => booking.business)
|
||||||
bookings!: BookingEntity[];
|
bookings!: BookingEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => BusinessAvailabilitySlotEntity, (slot) => slot.business, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
availabilitySlots!: BusinessAvailabilitySlotEntity[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ import { UpdateBookingDto } from './dto/update-booking.dto';
|
|||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(private readonly service: BookingsService) {}
|
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()
|
@Get()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BookingEntity } from '../../entities/booking.entity';
|
import { BookingEntity } from '../../entities/booking.entity';
|
||||||
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
|
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
@@ -15,6 +17,8 @@ import { BookingsService } from './bookings.service';
|
|||||||
UserEntity,
|
UserEntity,
|
||||||
BusinessEntity,
|
BusinessEntity,
|
||||||
BusinessServiceEntity,
|
BusinessServiceEntity,
|
||||||
|
BusinessAvailabilitySlotEntity,
|
||||||
|
ServiceTemplateEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { BookingEntity, BookingStatus } from '../../entities/booking.entity';
|
||||||
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
@@ -35,6 +44,8 @@ export class BookingsService {
|
|||||||
private readonly businessRepo: Repository<BusinessEntity>,
|
private readonly businessRepo: Repository<BusinessEntity>,
|
||||||
@InjectRepository(BusinessServiceEntity)
|
@InjectRepository(BusinessServiceEntity)
|
||||||
private readonly businessServiceRepo: Repository<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 raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
||||||
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||||
@@ -118,7 +129,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
const businessService = await this.businessServiceRepo.findOne({
|
const businessService = await this.businessServiceRepo.findOne({
|
||||||
where: { id: dto.businessServiceId },
|
where: { id: dto.businessServiceId },
|
||||||
relations: { business: true },
|
relations: { business: true, serviceTemplate: true },
|
||||||
});
|
});
|
||||||
if (!businessService) {
|
if (!businessService) {
|
||||||
throw new NotFoundException('Business service not found');
|
throw new NotFoundException('Business service not found');
|
||||||
@@ -131,7 +142,16 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
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({
|
const entity = this.repo.create({
|
||||||
customerId: dto.customerId,
|
customerId: dto.customerId,
|
||||||
@@ -156,7 +176,17 @@ export class BookingsService {
|
|||||||
|
|
||||||
if (dto.scheduledAt !== undefined) {
|
if (dto.scheduledAt !== undefined) {
|
||||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
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;
|
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 {
|
private parseScheduledAt(raw: string): Date {
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
if (Number.isNaN(d.getTime())) {
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -25,6 +26,7 @@ import { BusinessImagesService } from './business-images.service';
|
|||||||
import { BusinessesService } from './businesses.service';
|
import { BusinessesService } from './businesses.service';
|
||||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||||
|
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||||
|
|
||||||
@Controller('businesses')
|
@Controller('businesses')
|
||||||
@@ -55,6 +57,22 @@ export class BusinessesController {
|
|||||||
return this.service.findMine(user.id);
|
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')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string): Promise<BusinessEntity> {
|
findOne(@Param('id') id: string): Promise<BusinessEntity> {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||||
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
@@ -16,6 +17,7 @@ import { BusinessesService } from './businesses.service';
|
|||||||
BusinessCategoryEntity,
|
BusinessCategoryEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
BusinessImageEntity,
|
BusinessImageEntity,
|
||||||
|
BusinessAvailabilitySlotEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'
|
|||||||
import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
|
import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
|
||||||
import { BusinessImagesService } from './business-images.service';
|
import { BusinessImagesService } from './business-images.service';
|
||||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
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 { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||||
|
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||||
|
|
||||||
type FindAllFilters = {
|
type FindAllFilters = {
|
||||||
@@ -28,6 +31,8 @@ export class BusinessesService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(BusinessEntity)
|
@InjectRepository(BusinessEntity)
|
||||||
private readonly repo: Repository<BusinessEntity>,
|
private readonly repo: Repository<BusinessEntity>,
|
||||||
|
@InjectRepository(BusinessAvailabilitySlotEntity)
|
||||||
|
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
||||||
@InjectRepository(BusinessCategoryEntity)
|
@InjectRepository(BusinessCategoryEntity)
|
||||||
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
|
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
|
||||||
@InjectRepository(UserEntity)
|
@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(
|
async assignOwner(
|
||||||
id: string,
|
id: string,
|
||||||
dto: AssignBusinessOwnerDto,
|
dto: AssignBusinessOwnerDto,
|
||||||
@@ -192,6 +243,27 @@ export class BusinessesService {
|
|||||||
return normalized;
|
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(
|
private assertCanUpdateBusiness(
|
||||||
business: BusinessEntity,
|
business: BusinessEntity,
|
||||||
payload: UpdateBusinessDto,
|
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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user