diff --git a/src/database/migrations/1789046400000-AddBusinessSlotStepMinutes.ts b/src/database/migrations/1789046400000-AddBusinessSlotStepMinutes.ts new file mode 100644 index 0000000..61b0bef --- /dev/null +++ b/src/database/migrations/1789046400000-AddBusinessSlotStepMinutes.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBusinessSlotStepMinutes1789046400000 + implements MigrationInterface +{ + name = 'AddBusinessSlotStepMinutes1789046400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + ADD COLUMN IF NOT EXISTS "slotStepMinutes" integer NOT NULL DEFAULT 30 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" DROP COLUMN IF EXISTS "slotStepMinutes" + `); + } +} diff --git a/src/database/migrations/1789046500000-SetBusinessSlotStepDefault30.ts b/src/database/migrations/1789046500000-SetBusinessSlotStepDefault30.ts new file mode 100644 index 0000000..4826e0a --- /dev/null +++ b/src/database/migrations/1789046500000-SetBusinessSlotStepDefault30.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SetBusinessSlotStepDefault301789046500000 + implements MigrationInterface +{ + name = 'SetBusinessSlotStepDefault301789046500000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + ALTER COLUMN "slotStepMinutes" SET DEFAULT 30 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + ALTER COLUMN "slotStepMinutes" SET DEFAULT 15 + `); + } +} diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 1d12f39..a3cd8cd 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -53,6 +53,10 @@ export class BusinessEntity { @Column({ type: 'int', default: 1 }) concurrentCapacity!: number; + /** Minutes between bookable slot start times (e.g. 30 → 10:00, 10:30, 11:00) */ + @Column({ type: 'int', default: 30 }) + slotStepMinutes!: number; + @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { onDelete: 'RESTRICT', }) diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index d228b55..dcb9b3c 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -388,13 +388,14 @@ export class BookingsService { const windows = await this.loadAvailabilityWindows(businessId); const busy = await this.loadBusyIntervals(businessId, dateYmd); - const concurrentCapacity = await this.getConcurrentCapacity(businessId); + const { concurrentCapacity, slotStepMinutes } = + await this.getBusinessBookingSettings(businessId); const slots = generateAvailableSlotStarts({ dateYmd, durationMinutes: totalDurationMinutes, leadMinutes: this.minLeadMinutes, - slotStepMinutes: 15, + slotStepMinutes, windows, busy, concurrentCapacity, @@ -403,13 +404,18 @@ export class BookingsService { return { date: dateYmd, slots, totalDurationMinutes }; } - private async getConcurrentCapacity(businessId: string): Promise { + private async getBusinessBookingSettings(businessId: string): Promise<{ + concurrentCapacity: number; + slotStepMinutes: number; + }> { const business = await this.businessRepo.findOne({ where: { id: businessId }, - select: ['id', 'concurrentCapacity'], + select: ['id', 'concurrentCapacity', 'slotStepMinutes'], }); - const value = business?.concurrentCapacity ?? 1; - return Math.max(1, value); + return { + concurrentCapacity: Math.max(1, business?.concurrentCapacity ?? 1), + slotStepMinutes: Math.max(5, business?.slotStepMinutes ?? 30), + }; } private async loadAvailabilityWindows( @@ -658,12 +664,13 @@ export class BookingsService { ); } - const concurrentCapacity = await this.getConcurrentCapacity(businessId); + const { concurrentCapacity, slotStepMinutes } = + await this.getBusinessBookingSettings(businessId); const slots = generateAvailableSlotStarts({ dateYmd, durationMinutes, leadMinutes: this.minLeadMinutes, - slotStepMinutes: 15, + slotStepMinutes, windows, busy: await this.loadBusyIntervals(businessId, dateYmd, { excludeBookingId, diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index 6c3f59f..6db53fd 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -91,4 +91,16 @@ export class UpdateBusinessDto { @Min(1) @Max(20) concurrentCapacity?: number; + + @ApiPropertyOptional({ + example: 30, + description: 'Minutes between available booking slot start times', + minimum: 5, + maximum: 120, + }) + @IsOptional() + @IsInt() + @Min(5) + @Max(120) + slotStepMinutes?: number; }