diff --git a/src/app.module.ts b/src/app.module.ts index 30a2699..9c55988 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { BusinessAttributeValuesModule } from './modules/business-attribute-valu import { BookingsModule } from './modules/bookings/bookings.module'; import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module'; import { BusinessServiceRequestsModule } from './modules/business-service-requests/business-service-requests.module'; +import { BusinessServiceClassesModule } from './modules/business-service-classes/business-service-classes.module'; import { AuthModule } from './modules/auth/auth.module'; import { UsersModule } from './modules/users/users.module'; @@ -29,6 +30,7 @@ import { UsersModule } from './modules/users/users.module'; BookingsModule, BusinessRegistrationRequestsModule, BusinessServiceRequestsModule, + BusinessServiceClassesModule, AuthModule, UsersModule, ], diff --git a/src/common/utils/business-cascade.util.ts b/src/common/utils/business-cascade.util.ts index 4a42576..46df1a7 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 { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessEntity } from '../../entities/business.entity'; @@ -20,6 +21,7 @@ export async function deleteBusinessCascade( await manager.delete(BusinessServiceRequestEntity, { businessId }); await manager.delete(BusinessAvailabilitySlotEntity, { businessId }); await manager.delete(BusinessServiceEntity, { businessId }); + await manager.delete(BusinessServiceClassEntity, { businessId }); await manager.delete(BusinessAttributeValueEntity, { businessId }); await manager.delete(BusinessImageEntity, { businessId }); diff --git a/src/database/migrations/1789046000000-CreateBusinessServiceClasses.ts b/src/database/migrations/1789046000000-CreateBusinessServiceClasses.ts new file mode 100644 index 0000000..ae9866b --- /dev/null +++ b/src/database/migrations/1789046000000-CreateBusinessServiceClasses.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateBusinessServiceClasses1789046000000 + implements MigrationInterface +{ + name = 'CreateBusinessServiceClasses1789046000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "business_service_classes" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "businessId" uuid NOT NULL, + "title" varchar(120) NOT NULL, + "description" varchar(500), + "sortOrder" integer NOT NULL DEFAULT 0, + "isActive" boolean NOT NULL DEFAULT true, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_business_service_classes" PRIMARY KEY ("id"), + CONSTRAINT "UQ_business_service_class_title" UNIQUE ("businessId", "title"), + CONSTRAINT "FK_service_class_business" FOREIGN KEY ("businessId") + REFERENCES "businesses"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "business_service_class_prices" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "businessServiceId" uuid NOT NULL, + "classId" uuid NOT NULL, + "price" numeric(12,2) NOT NULL, + "durationMinutes" integer, + "isActive" boolean NOT NULL DEFAULT true, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_business_service_class_prices" PRIMARY KEY ("id"), + CONSTRAINT "UQ_business_service_class_price" UNIQUE ("businessServiceId", "classId"), + CONSTRAINT "FK_class_price_service" FOREIGN KEY ("businessServiceId") + REFERENCES "business_services"("id") ON DELETE CASCADE, + CONSTRAINT "FK_class_price_class" FOREIGN KEY ("classId") + REFERENCES "business_service_classes"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + ALTER TABLE "bookings" + ADD COLUMN IF NOT EXISTS "serviceClassId" uuid + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_booking_service_class' + ) THEN + ALTER TABLE "bookings" + ADD CONSTRAINT "FK_booking_service_class" + FOREIGN KEY ("serviceClassId") + REFERENCES "business_service_classes"("id") + ON DELETE RESTRICT; + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "bookings" DROP CONSTRAINT IF EXISTS "FK_booking_service_class" + `); + await queryRunner.query(` + ALTER TABLE "bookings" DROP COLUMN IF EXISTS "serviceClassId" + `); + await queryRunner.query(`DROP TABLE IF EXISTS "business_service_class_prices"`); + await queryRunner.query(`DROP TABLE IF EXISTS "business_service_classes"`); + } +} diff --git a/src/database/migrations/1789046100000-AddBusinessServiceClassesEnabled.ts b/src/database/migrations/1789046100000-AddBusinessServiceClassesEnabled.ts new file mode 100644 index 0000000..234272a --- /dev/null +++ b/src/database/migrations/1789046100000-AddBusinessServiceClassesEnabled.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBusinessServiceClassesEnabled1789046100000 + implements MigrationInterface +{ + name = 'AddBusinessServiceClassesEnabled1789046100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + ADD COLUMN IF NOT EXISTS "serviceClassesEnabled" boolean NOT NULL DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" DROP COLUMN IF EXISTS "serviceClassesEnabled" + `); + } +} diff --git a/src/database/migrations/1789046200000-AddBookingGroupId.ts b/src/database/migrations/1789046200000-AddBookingGroupId.ts new file mode 100644 index 0000000..1b402be --- /dev/null +++ b/src/database/migrations/1789046200000-AddBookingGroupId.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingGroupId1789046200000 implements MigrationInterface { + name = 'AddBookingGroupId1789046200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "bookings" + ADD COLUMN IF NOT EXISTS "bookingGroupId" uuid + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_booking_group" + ON "bookings" ("bookingGroupId") + WHERE "bookingGroupId" IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_booking_group"`); + await queryRunner.query(` + ALTER TABLE "bookings" DROP COLUMN IF EXISTS "bookingGroupId" + `); + } +} diff --git a/src/entities/booking.entity.ts b/src/entities/booking.entity.ts index e39128e..e98e837 100644 --- a/src/entities/booking.entity.ts +++ b/src/entities/booking.entity.ts @@ -9,6 +9,7 @@ import { } from 'typeorm'; import { BusinessEntity } from './business.entity'; import { BusinessServiceEntity } from './business-service.entity'; +import { BusinessServiceClassEntity } from './business-service-class.entity'; import { UserEntity } from './user.entity'; export enum BookingStatus { @@ -46,6 +47,16 @@ export class BookingEntity { @Column('uuid') businessServiceId!: string; + @ManyToOne(() => BusinessServiceClassEntity, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'serviceClassId' }) + serviceClass!: BusinessServiceClassEntity | null; + + @Column({ type: 'uuid', nullable: true }) + serviceClassId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + bookingGroupId!: string | null; + @Column({ type: 'timestamptz' }) scheduledAt!: Date; diff --git a/src/entities/business-service-class-price.entity.ts b/src/entities/business-service-class-price.entity.ts new file mode 100644 index 0000000..1d67e26 --- /dev/null +++ b/src/entities/business-service-class-price.entity.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { BusinessServiceClassEntity } from './business-service-class.entity'; +import { BusinessServiceEntity } from './business-service.entity'; + +@Entity('business_service_class_prices') +@Unique('UQ_business_service_class_price', ['businessServiceId', 'classId']) +export class BusinessServiceClassPriceEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BusinessServiceEntity, (service) => service.classPrices, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'businessServiceId' }) + businessService!: BusinessServiceEntity; + + @Column('uuid') + businessServiceId!: string; + + @ManyToOne(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.classPrices, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'classId' }) + serviceClass!: BusinessServiceClassEntity; + + @Column('uuid') + classId!: string; + + @Column({ type: 'numeric', precision: 12, scale: 2 }) + price!: string; + + @Column({ type: 'int', nullable: true }) + durationMinutes!: number | null; + + @Column({ default: true }) + isActive!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/business-service-class.entity.ts b/src/entities/business-service-class.entity.ts new file mode 100644 index 0000000..b96caf2 --- /dev/null +++ b/src/entities/business-service-class.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { BusinessEntity } from './business.entity'; +import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity'; + +@Entity('business_service_classes') +@Unique('UQ_business_service_class_title', ['businessId', 'title']) +export class BusinessServiceClassEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BusinessEntity, (business) => business.serviceClasses, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'businessId' }) + business!: BusinessEntity; + + @Column('uuid') + businessId!: string; + + @Column({ length: 120 }) + title!: string; + + @Column({ type: 'varchar', nullable: true, length: 500 }) + description!: string | null; + + @Column({ type: 'int', default: 0 }) + sortOrder!: number; + + @Column({ default: true }) + isActive!: boolean; + + @OneToMany(() => BusinessServiceClassPriceEntity, (price) => price.serviceClass) + classPrices!: BusinessServiceClassPriceEntity[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/business-service.entity.ts b/src/entities/business-service.entity.ts index 1a992aa..4fbad31 100644 --- a/src/entities/business-service.entity.ts +++ b/src/entities/business-service.entity.ts @@ -10,6 +10,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { BookingEntity } from './booking.entity'; +import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity'; import { BusinessEntity } from './business.entity'; import { ServiceTemplateEntity } from './service-template.entity'; @@ -49,6 +50,9 @@ export class BusinessServiceEntity { @OneToMany(() => BookingEntity, (booking) => booking.businessService) bookings!: BookingEntity[]; + @OneToMany(() => BusinessServiceClassPriceEntity, (classPrice) => classPrice.businessService) + classPrices!: BusinessServiceClassPriceEntity[]; + @CreateDateColumn() createdAt!: Date; diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 0b1705c..2cbfca6 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -14,6 +14,7 @@ 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 { BusinessServiceClassEntity } from './business-service-class.entity'; import { UserEntity } from './user.entity'; @Entity('businesses') @@ -45,6 +46,9 @@ export class BusinessEntity { @Column({ default: true }) isActive!: boolean; + @Column({ default: false }) + serviceClassesEnabled!: boolean; + @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { onDelete: 'RESTRICT', }) @@ -72,6 +76,9 @@ export class BusinessEntity { @OneToMany(() => BusinessServiceEntity, (service) => service.business) services!: BusinessServiceEntity[]; + @OneToMany(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.business) + serviceClasses!: BusinessServiceClassEntity[]; + @OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business) attributes!: BusinessAttributeValueEntity[]; diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index 4543d90..dfebe3e 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { BookingsService } from './bookings.service'; import { CreateBookingDto } from './dto/create-booking.dto'; +import { CreateBookingBatchDto } from './dto/create-booking-batch.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; @Controller('bookings') @@ -28,10 +29,20 @@ export class BookingsController { @Get('available-slots') getAvailableSlots( @Query('businessId') businessId: string, - @Query('businessServiceId') businessServiceId: string, @Query('date') date: string, + @Query('businessServiceId') businessServiceId?: string, + @Query('businessServiceIds') businessServiceIds?: string, + @Query('serviceClassId') serviceClassId?: string, ) { - return this.service.getAvailableSlots(businessId, businessServiceId, date); + const ids = businessServiceIds + ? businessServiceIds.split(',').map((id) => id.trim()).filter(Boolean) + : undefined; + + return this.service.getAvailableSlots(businessId, date, { + businessServiceId, + businessServiceIds: ids, + serviceClassId, + }); } @Get() @@ -71,6 +82,16 @@ export class BookingsController { return this.service.create(payload, user); } + @Post('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + createBatch( + @Body() payload: CreateBookingBatchDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.createBatch(payload, user); + } + @Patch(':id') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) diff --git a/src/modules/bookings/bookings.module.ts b/src/modules/bookings/bookings.module.ts index 8bde5cf..c2db3b4 100644 --- a/src/modules/bookings/bookings.module.ts +++ b/src/modules/bookings/bookings.module.ts @@ -2,6 +2,8 @@ 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 { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; @@ -18,6 +20,8 @@ import { BookingsService } from './bookings.service'; BusinessEntity, BusinessServiceEntity, BusinessAvailabilitySlotEntity, + BusinessServiceClassEntity, + BusinessServiceClassPriceEntity, ServiceTemplateEntity, ]), AuthModule, diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index cca4d31..974d80a 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -6,7 +6,8 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { randomUUID } from 'crypto'; +import { In, Repository } from 'typeorm'; import { generateAvailableSlotStarts, parseGregorianYmd, @@ -17,11 +18,14 @@ import { } from '../../common/utils/booking-schedule.util'; import { BookingEntity, BookingStatus } from '../../entities/booking.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; +import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; import { CreateBookingDto } from './dto/create-booking.dto'; +import { CreateBookingBatchDto } from './dto/create-booking-batch.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; type FindAllFilters = { @@ -47,6 +51,10 @@ export class BookingsService { private readonly businessServiceRepo: Repository, @InjectRepository(BusinessAvailabilitySlotEntity) private readonly availabilityRepo: Repository, + @InjectRepository(BusinessServiceClassEntity) + private readonly serviceClassRepo: Repository, + @InjectRepository(BusinessServiceClassPriceEntity) + private readonly classPriceRepo: Repository, ) { const raw = this.config.get('BOOKING_MIN_LEAD_MINUTES'); const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN; @@ -63,6 +71,7 @@ export class BookingsService { .leftJoinAndSelect('b.business', 'business') .leftJoinAndSelect('b.businessService', 'businessService') .leftJoinAndSelect('businessService.serviceTemplate', 'template') + .leftJoinAndSelect('b.serviceClass', 'serviceClass') .orderBy('b.scheduledAt', 'ASC'); if (actor?.role === UserRole.CUSTOMER) { @@ -108,6 +117,7 @@ export class BookingsService { customer: true, business: true, businessService: { serviceTemplate: true }, + serviceClass: true, }, }); if (!row) { @@ -151,21 +161,21 @@ export class BookingsService { } const scheduledAt = this.parseScheduledAt(dto.scheduledAt); - const durationMinutes = resolveServiceDurationMinutes( - businessService.durationMinutes, - businessService.serviceTemplate?.defaultDurationMinutes, + const pricing = await this.resolveBookingPricing( + businessService, + dto.serviceClassId ?? null, ); await this.assertSchedulableSlot({ businessId: dto.businessId, - businessServiceId: dto.businessServiceId, scheduledAt, - durationMinutes, + durationMinutes: pricing.durationMinutes, }); const entity = this.repo.create({ customerId: dto.customerId, businessId: dto.businessId, businessServiceId: dto.businessServiceId, + serviceClassId: pricing.serviceClassId, scheduledAt, note: dto.note ?? null, status: BookingStatus.PENDING, @@ -174,6 +184,93 @@ export class BookingsService { return this.repo.save(entity); } + async createBatch( + dto: CreateBookingBatchDto, + actor?: AuthUser, + ): Promise { + if (actor && dto.customerId !== actor.id) { + throw new ForbiddenException('You can only book for yourself'); + } + + const customer = await this.userRepo.findOne({ + where: { id: dto.customerId }, + }); + if (!customer) { + throw new NotFoundException('User not found'); + } + + const business = await this.businessRepo.findOne({ + where: { id: dto.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + const serviceIds = [ + ...new Set(dto.items.map((item) => item.businessServiceId)), + ]; + if (serviceIds.length !== dto.items.length) { + throw new BadRequestException('Duplicate services in booking batch'); + } + + const scheduledAt = this.parseScheduledAt(dto.scheduledAt); + const serviceClassId = dto.serviceClassId ?? null; + + const resolvedItems: Array<{ + businessService: BusinessServiceEntity; + serviceClassId: string | null; + durationMinutes: number; + }> = []; + + let totalDurationMinutes = 0; + for (const serviceId of serviceIds) { + const businessService = await this.businessServiceRepo.findOne({ + where: { id: serviceId, businessId: dto.businessId, isActive: true }, + relations: { serviceTemplate: true }, + }); + if (!businessService) { + throw new NotFoundException( + `Business service ${serviceId} not found or inactive`, + ); + } + + const pricing = await this.resolveBookingPricing( + businessService, + serviceClassId, + ); + resolvedItems.push({ + businessService, + serviceClassId: pricing.serviceClassId, + durationMinutes: pricing.durationMinutes, + }); + totalDurationMinutes += pricing.durationMinutes; + } + + await this.assertSchedulableSlot({ + businessId: dto.businessId, + scheduledAt, + durationMinutes: totalDurationMinutes, + }); + + const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null; + const note = dto.note ?? null; + + const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) => + this.repo.create({ + customerId: dto.customerId, + businessId: dto.businessId, + businessServiceId: businessService.id, + serviceClassId: classId, + bookingGroupId, + scheduledAt, + note, + status: BookingStatus.PENDING, + }), + ); + + return this.repo.save(entities); + } + async update( id: string, dto: UpdateBookingDto, @@ -185,18 +282,21 @@ export class BookingsService { if (dto.scheduledAt !== undefined) { const scheduledAt = this.parseScheduledAt(dto.scheduledAt); - const durationMinutes = resolveServiceDurationMinutes( - row.businessService?.durationMinutes, - row.businessService?.serviceTemplate?.defaultDurationMinutes, - ); + const durationMinutes = await this.resolveGroupDuration(row); await this.assertSchedulableSlot({ businessId: row.businessId, - businessServiceId: row.businessServiceId, scheduledAt, durationMinutes, excludeBookingId: row.id, + excludeBookingGroupId: row.bookingGroupId ?? undefined, }); row.scheduledAt = scheduledAt; + if (row.bookingGroupId) { + await this.repo.update( + { bookingGroupId: row.bookingGroupId }, + { scheduledAt }, + ); + } } if (dto.status !== undefined) { @@ -240,9 +340,13 @@ export class BookingsService { async getAvailableSlots( businessId: string, - businessServiceId: string, dateYmd: string, - ): Promise<{ date: string; slots: string[] }> { + options: { + businessServiceId?: string; + businessServiceIds?: string[]; + serviceClassId?: string; + }, + ): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> { parseGregorianYmd(dateYmd); const business = await this.businessRepo.findOne({ @@ -252,32 +356,49 @@ export class BookingsService { 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 serviceIds = + options.businessServiceIds?.length + ? options.businessServiceIds + : options.businessServiceId + ? [options.businessServiceId] + : []; + + if (!serviceIds.length) { + throw new BadRequestException('At least one business service is required'); } - const durationMinutes = resolveServiceDurationMinutes( - businessService.durationMinutes, - businessService.serviceTemplate?.defaultDurationMinutes, - ); + const serviceClassId = options.serviceClassId ?? null; + let totalDurationMinutes = 0; + + for (const serviceId of serviceIds) { + const businessService = await this.businessServiceRepo.findOne({ + where: { id: serviceId, businessId, isActive: true }, + relations: { serviceTemplate: true }, + }); + if (!businessService) { + throw new NotFoundException('Business service not found or inactive'); + } + + const pricing = await this.resolveBookingPricing( + businessService, + serviceClassId, + ); + totalDurationMinutes += pricing.durationMinutes; + } const windows = await this.loadAvailabilityWindows(businessId); const busy = await this.loadBusyIntervals(businessId, dateYmd); const slots = generateAvailableSlotStarts({ dateYmd, - durationMinutes, + durationMinutes: totalDurationMinutes, leadMinutes: this.minLeadMinutes, slotStepMinutes: 15, windows, busy, }); - return { date: dateYmd, slots }; + return { date: dateYmd, slots, totalDurationMinutes }; } private async loadAvailabilityWindows( @@ -294,11 +415,20 @@ export class BookingsService { private async loadBusyIntervals( businessId: string, dateYmd: string, - excludeBookingId?: string, + options?: { excludeBookingId?: string; excludeBookingGroupId?: string }, ): Promise { const dayStart = new Date(`${dateYmd}T00:00:00+03:30`); const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`); + let excludeGroupId = options?.excludeBookingGroupId ?? null; + if (options?.excludeBookingId && !excludeGroupId) { + const excluded = await this.repo.findOne({ + where: { id: options.excludeBookingId }, + select: ['id', 'bookingGroupId'], + }); + excludeGroupId = excluded?.bookingGroupId ?? null; + } + const qb = this.repo .createQueryBuilder('b') .leftJoinAndSelect('b.businessService', 'bs') @@ -310,34 +440,202 @@ export class BookingsService { .andWhere('b.scheduledAt >= :dayStart', { dayStart }) .andWhere('b.scheduledAt <= :dayEnd', { dayEnd }); - if (excludeBookingId) { - qb.andWhere('b.id != :excludeBookingId', { excludeBookingId }); + if (options?.excludeBookingId && !excludeGroupId) { + qb.andWhere('b.id != :excludeBookingId', { + excludeBookingId: options.excludeBookingId, + }); + } + if (excludeGroupId) { + qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', { + excludeGroupId, + }); } const bookings = await qb.getMany(); + const durations = await Promise.all( + bookings.map((b) => this.resolveBookingDuration(b)), + ); - 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, - }; + const groupTotals = new Map< + string, + { startMs: number; totalDuration: number } + >(); + const singles: BusyInterval[] = []; + + for (const [index, booking] of bookings.entries()) { + const duration = durations[index] ?? 30; + const startMs = booking.scheduledAt.getTime(); + + if (booking.bookingGroupId) { + const key = `${booking.bookingGroupId}:${startMs}`; + const existing = groupTotals.get(key); + if (existing) { + existing.totalDuration += duration; + } else { + groupTotals.set(key, { startMs, totalDuration: duration }); + } + } else { + singles.push({ + startMs, + endMs: startMs + duration * 60 * 1000, + }); + } + } + + return [ + ...singles, + ...Array.from(groupTotals.values()).map((group) => ({ + startMs: group.startMs, + endMs: group.startMs + group.totalDuration * 60 * 1000, + })), + ]; + } + + private async resolveBookingPricing( + businessService: BusinessServiceEntity, + serviceClassId: string | null, + ): Promise<{ serviceClassId: string | null; durationMinutes: number }> { + const business = await this.businessRepo.findOne({ + where: { id: businessService.businessId }, }); + const activeClassCount = await this.serviceClassRepo.count({ + where: { businessId: businessService.businessId, isActive: true }, + }); + + const usesClasses = + business?.serviceClassesEnabled === true && activeClassCount > 0; + + if (!usesClasses) { + if (serviceClassId) { + throw new BadRequestException( + 'This business does not use service classes', + ); + } + return { + serviceClassId: null, + durationMinutes: resolveServiceDurationMinutes( + businessService.durationMinutes, + businessService.serviceTemplate?.defaultDurationMinutes, + ), + }; + } + + if (!serviceClassId) { + throw new BadRequestException('serviceClassId is required for this business'); + } + + const serviceClass = await this.serviceClassRepo.findOne({ + where: { + id: serviceClassId, + businessId: businessService.businessId, + isActive: true, + }, + }); + if (!serviceClass) { + throw new BadRequestException('Service class not found or inactive'); + } + + const classPrice = await this.classPriceRepo.findOne({ + where: { + businessServiceId: businessService.id, + classId: serviceClassId, + isActive: true, + }, + }); + if (!classPrice) { + throw new BadRequestException( + 'This service is not available for the selected class', + ); + } + + return { + serviceClassId, + durationMinutes: resolveServiceDurationMinutes( + classPrice.durationMinutes ?? businessService.durationMinutes, + businessService.serviceTemplate?.defaultDurationMinutes, + ), + }; + } + + private async resolveGroupDuration(booking: BookingEntity): Promise { + if (!booking.bookingGroupId) { + return this.resolveBookingDuration(booking); + } + + const siblings = await this.repo.find({ + where: { + bookingGroupId: booking.bookingGroupId, + status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]), + }, + relations: { businessService: { serviceTemplate: true } }, + }); + + const rows = siblings.length + ? siblings + : [ + { + ...booking, + businessService: + booking.businessService ?? + (await this.businessServiceRepo.findOne({ + where: { id: booking.businessServiceId }, + relations: { serviceTemplate: true }, + })), + } as BookingEntity, + ]; + + let total = 0; + for (const row of rows) { + total += await this.resolveBookingDuration(row); + } + return total; + } + + private async resolveBookingDuration(booking: BookingEntity): Promise { + if (!booking.businessService) { + const businessService = await this.businessServiceRepo.findOne({ + where: { id: booking.businessServiceId }, + relations: { serviceTemplate: true }, + }); + if (!businessService) { + return 30; + } + booking.businessService = businessService; + } + + if (booking.serviceClassId) { + const classPrice = await this.classPriceRepo.findOne({ + where: { + businessServiceId: booking.businessServiceId, + classId: booking.serviceClassId, + }, + }); + return resolveServiceDurationMinutes( + classPrice?.durationMinutes ?? booking.businessService.durationMinutes, + booking.businessService.serviceTemplate?.defaultDurationMinutes, + ); + } + + return resolveServiceDurationMinutes( + booking.businessService.durationMinutes, + booking.businessService.serviceTemplate?.defaultDurationMinutes, + ); } private async assertSchedulableSlot(params: { businessId: string; - businessServiceId: string; scheduledAt: Date; durationMinutes: number; excludeBookingId?: string; + excludeBookingGroupId?: string; }): Promise { - const { businessId, scheduledAt, durationMinutes, excludeBookingId } = - params; + const { + businessId, + scheduledAt, + durationMinutes, + excludeBookingId, + excludeBookingGroupId, + } = params; this.assertLeadTime(scheduledAt); @@ -355,11 +653,10 @@ export class BookingsService { leadMinutes: this.minLeadMinutes, slotStepMinutes: 15, windows, - busy: await this.loadBusyIntervals( - businessId, - dateYmd, + busy: await this.loadBusyIntervals(businessId, dateYmd, { excludeBookingId, - ), + excludeBookingGroupId, + }), }); const scheduledMs = scheduledAt.getTime(); diff --git a/src/modules/bookings/dto/create-booking-batch.dto.ts b/src/modules/bookings/dto/create-booking-batch.dto.ts new file mode 100644 index 0000000..c4a892d --- /dev/null +++ b/src/modules/bookings/dto/create-booking-batch.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; + +export class CreateBookingBatchItemDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + businessServiceId!: string; +} + +export class CreateBookingBatchDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + customerId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + businessId!: string; + + @ApiProperty({ type: [CreateBookingBatchItemDto], minItems: 1 }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingBatchItemDto) + items!: CreateBookingBatchItemDto[]; + + @ApiProperty({ example: '2026-05-13T10:00:00.000Z' }) + @IsISO8601() + scheduledAt!: string; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + @IsOptional() + @IsUUID() + serviceClassId?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string | null; +} diff --git a/src/modules/bookings/dto/create-booking.dto.ts b/src/modules/bookings/dto/create-booking.dto.ts index b59a9ab..12de7fc 100644 --- a/src/modules/bookings/dto/create-booking.dto.ts +++ b/src/modules/bookings/dto/create-booking.dto.ts @@ -14,6 +14,11 @@ export class CreateBookingDto { @IsUUID() businessServiceId!: string; + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + @IsOptional() + @IsUUID() + serviceClassId?: string | null; + /** ISO 8601 datetime string */ @ApiProperty({ example: '2026-05-13T10:00:00.000Z' }) @IsISO8601() diff --git a/src/modules/business-service-classes/business-service-classes.controller.ts b/src/modules/business-service-classes/business-service-classes.controller.ts new file mode 100644 index 0000000..b015a2b --- /dev/null +++ b/src/modules/business-service-classes/business-service-classes.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { BusinessServiceClassesService } from './business-service-classes.service'; +import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto'; +import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto'; + +@Controller('business-service-classes') +export class BusinessServiceClassesController { + constructor(private readonly service: BusinessServiceClassesService) {} + + @Get() + findAll( + @Query('businessId') businessId?: string, + @Query('isActive') isActive?: string, + ): Promise { + const parsedIsActive = + typeof isActive === 'string' ? isActive === 'true' : undefined; + + return this.service.findAll({ businessId, isActive: parsedIsActive }); + } + + @Get(':id') + findOne(@Param('id') id: string): Promise { + return this.service.findOne(id); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + create( + @Body() payload: CreateBusinessServiceClassDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.create(payload, user); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + update( + @Param('id') id: string, + @Body() payload: UpdateBusinessServiceClassDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.update(id, payload, user); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + async remove( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise<{ success: true }> { + await this.service.remove(id, user); + return { success: true }; + } +} diff --git a/src/modules/business-service-classes/business-service-classes.module.ts b/src/modules/business-service-classes/business-service-classes.module.ts new file mode 100644 index 0000000..d6aa383 --- /dev/null +++ b/src/modules/business-service-classes/business-service-classes.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BusinessEntity } from '../../entities/business.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessServiceClassesController } from './business-service-classes.controller'; +import { BusinessServiceClassesService } from './business-service-classes.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BusinessServiceClassEntity, BusinessEntity]), + AuthModule, + ], + controllers: [BusinessServiceClassesController], + providers: [BusinessServiceClassesService], + exports: [BusinessServiceClassesService], +}) +export class BusinessServiceClassesModule {} diff --git a/src/modules/business-service-classes/business-service-classes.service.ts b/src/modules/business-service-classes/business-service-classes.service.ts new file mode 100644 index 0000000..5660836 --- /dev/null +++ b/src/modules/business-service-classes/business-service-classes.service.ts @@ -0,0 +1,168 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BusinessEntity } from '../../entities/business.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto'; +import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto'; + +type FindAllFilters = { + businessId?: string; + isActive?: boolean; +}; + +@Injectable() +export class BusinessServiceClassesService { + constructor( + @InjectRepository(BusinessServiceClassEntity) + private readonly repo: Repository, + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + ) {} + + async findAll(filters: FindAllFilters): Promise { + const query = this.repo + .createQueryBuilder('c') + .orderBy('c.sortOrder', 'ASC') + .addOrderBy('c.createdAt', 'ASC'); + + if (filters.businessId) { + query.andWhere('c.businessId = :businessId', { + businessId: filters.businessId, + }); + } + if (typeof filters.isActive === 'boolean') { + query.andWhere('c.isActive = :isActive', { isActive: filters.isActive }); + } + + return query.getMany(); + } + + async findOne(id: string): Promise { + const row = await this.repo.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException('Service class not found'); + } + return row; + } + + async create( + dto: CreateBusinessServiceClassDto, + actor?: AuthUser, + ): Promise { + const business = await this.businessRepo.findOne({ + where: { id: dto.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + this.assertCanManageBusiness(business, actor); + + const dup = await this.repo.findOne({ + where: { businessId: dto.businessId, title: dto.title.trim() }, + }); + if (dup) { + throw new ConflictException('A class with this title already exists'); + } + + let sortOrder = dto.sortOrder; + if (sortOrder === undefined) { + const max = await this.repo + .createQueryBuilder('c') + .select('MAX(c.sortOrder)', 'max') + .where('c.businessId = :businessId', { businessId: dto.businessId }) + .getRawOne<{ max: string | null }>(); + sortOrder = (max?.max ? Number.parseInt(max.max, 10) : -1) + 1; + } + + const entity = this.repo.create({ + businessId: dto.businessId, + title: dto.title.trim(), + description: dto.description?.trim() || null, + sortOrder, + isActive: dto.isActive ?? true, + }); + + return this.repo.save(entity); + } + + async update( + id: string, + dto: UpdateBusinessServiceClassDto, + actor?: AuthUser, + ): Promise { + const row = await this.findOne(id); + const business = await this.businessRepo.findOne({ + where: { id: row.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + this.assertCanManageBusiness(business, actor); + + if (dto.title !== undefined) { + const title = dto.title.trim(); + if (!title) { + throw new BadRequestException('title cannot be empty'); + } + const dup = await this.repo.findOne({ + where: { businessId: row.businessId, title }, + }); + if (dup && dup.id !== row.id) { + throw new ConflictException('A class with this title already exists'); + } + row.title = title; + } + if (dto.description !== undefined) { + row.description = dto.description?.trim() || null; + } + if (dto.sortOrder !== undefined) { + row.sortOrder = dto.sortOrder; + } + if (dto.isActive !== undefined) { + row.isActive = dto.isActive; + } + + return this.repo.save(row); + } + + async remove(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + const business = await this.businessRepo.findOne({ + where: { id: row.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + this.assertCanManageBusiness(business, actor); + + const result = await this.repo.delete(id); + if (!result.affected) { + throw new NotFoundException('Service class not found'); + } + } + + private assertCanManageBusiness( + 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'); + } + } +} diff --git a/src/modules/business-service-classes/dto/create-business-service-class.dto.ts b/src/modules/business-service-classes/dto/create-business-service-class.dto.ts new file mode 100644 index 0000000..72ad800 --- /dev/null +++ b/src/modules/business-service-classes/dto/create-business-service-class.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateBusinessServiceClassDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + businessId!: string; + + @ApiProperty({ example: 'کلاس ۱ — مینی و کوچک' }) + @IsString() + @MaxLength(120) + title!: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string | null; + + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/business-service-classes/dto/update-business-service-class.dto.ts b/src/modules/business-service-classes/dto/update-business-service-class.dto.ts new file mode 100644 index 0000000..7b047f7 --- /dev/null +++ b/src/modules/business-service-classes/dto/update-business-service-class.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateBusinessServiceClassDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(120) + title?: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/business-services/business-services.controller.ts b/src/modules/business-services/business-services.controller.ts index e70004d..c77380d 100644 --- a/src/modules/business-services/business-services.controller.ts +++ b/src/modules/business-services/business-services.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, Query, UseGuards, } from '@nestjs/common'; @@ -19,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessServicesService } from './business-services.service'; import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; +import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto'; @Controller('business-services') export class BusinessServicesController { @@ -66,6 +68,17 @@ export class BusinessServicesController { return this.service.update(id, payload, user); } + @Put(':id/class-prices') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + setClassPrices( + @Param('id') id: string, + @Body() payload: SetBusinessServiceClassPricesDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.setClassPrices(id, payload, user); + } + @Delete(':id') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) diff --git a/src/modules/business-services/business-services.module.ts b/src/modules/business-services/business-services.module.ts index 63e8809..a884cce 100644 --- a/src/modules/business-services/business-services.module.ts +++ b/src/modules/business-services/business-services.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BusinessEntity } from '../../entities/business.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; +import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; import { AuthModule } from '../auth/auth.module'; import { BusinessServicesController } from './business-services.controller'; @@ -13,6 +15,8 @@ import { BusinessServicesService } from './business-services.service'; BusinessServiceEntity, BusinessEntity, ServiceTemplateEntity, + BusinessServiceClassEntity, + BusinessServiceClassPriceEntity, ]), AuthModule, ], diff --git a/src/modules/business-services/business-services.service.ts b/src/modules/business-services/business-services.service.ts index 29b2e59..0689f8d 100644 --- a/src/modules/business-services/business-services.service.ts +++ b/src/modules/business-services/business-services.service.ts @@ -6,14 +6,17 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { BusinessEntity } from '../../entities/business.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; +import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; import { UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; +import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto'; type FindAllFilters = { businessId?: string; @@ -37,6 +40,10 @@ export class BusinessServicesService { private readonly businessRepo: Repository, @InjectRepository(ServiceTemplateEntity) private readonly templateRepo: Repository, + @InjectRepository(BusinessServiceClassEntity) + private readonly classRepo: Repository, + @InjectRepository(BusinessServiceClassPriceEntity) + private readonly classPriceRepo: Repository, ) {} async findAll(filters: FindAllFilters): Promise { @@ -44,6 +51,8 @@ export class BusinessServicesService { .createQueryBuilder('bs') .leftJoinAndSelect('bs.business', 'business') .leftJoinAndSelect('bs.serviceTemplate', 'template') + .leftJoinAndSelect('bs.classPrices', 'classPrices') + .leftJoinAndSelect('classPrices.serviceClass', 'serviceClass') .orderBy('bs.createdAt', 'DESC'); if (filters.businessId) { @@ -68,7 +77,11 @@ export class BusinessServicesService { async findOne(id: string): Promise { const row = await this.repo.findOne({ where: { id }, - relations: { business: true, serviceTemplate: true }, + relations: { + business: true, + serviceTemplate: true, + classPrices: { serviceClass: true }, + }, }); if (!row) { throw new NotFoundException('Business service not found'); @@ -193,6 +206,67 @@ export class BusinessServicesService { return this.repo.save(row); } + async setClassPrices( + id: string, + dto: SetBusinessServiceClassPricesDto, + actor?: AuthUser, + ): Promise { + const row = await this.findOne(id); + const business = await this.businessRepo.findOne({ + where: { id: row.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + this.assertCanManageBusiness(business, actor); + + const classIds = [...new Set(dto.prices.map((p) => p.classId))]; + const classes = await this.classRepo.find({ + where: { businessId: row.businessId, id: In(classIds) }, + }); + if (classes.length !== classIds.length) { + throw new BadRequestException( + 'One or more service classes do not belong to this business', + ); + } + + for (const item of dto.prices) { + const existing = await this.classPriceRepo.findOne({ + where: { businessServiceId: id, classId: item.classId }, + }); + const payload = { + price: normalizePrice(item.price), + durationMinutes: item.durationMinutes ?? null, + isActive: item.isActive ?? true, + }; + if (existing) { + Object.assign(existing, payload); + await this.classPriceRepo.save(existing); + } else { + await this.classPriceRepo.save( + this.classPriceRepo.create({ + businessServiceId: id, + classId: item.classId, + ...payload, + }), + ); + } + } + + const activePrices = await this.classPriceRepo.find({ + where: { businessServiceId: id, isActive: true }, + }); + if (activePrices.length) { + const minPrice = activePrices.reduce((min, p) => + Number.parseFloat(p.price) < Number.parseFloat(min.price) ? p : min, + ); + row.price = minPrice.price; + await this.repo.save(row); + } + + return this.findOne(id); + } + async remove(id: string, actor?: AuthUser): Promise { const row = await this.findOne(id); const business = await this.businessRepo.findOne({ diff --git a/src/modules/business-services/dto/set-business-service-class-prices.dto.ts b/src/modules/business-services/dto/set-business-service-class-prices.dto.ts new file mode 100644 index 0000000..065811b --- /dev/null +++ b/src/modules/business-services/dto/set-business-service-class-prices.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class BusinessServiceClassPriceItemDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + classId!: string; + + @ApiProperty({ example: 100000 }) + @IsNumber() + @Min(0) + price!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsInt() + @Min(1) + durationMinutes?: number | null; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class SetBusinessServiceClassPricesDto { + @ApiProperty({ type: [BusinessServiceClassPriceItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BusinessServiceClassPriceItemDto) + prices!: BusinessServiceClassPriceItemDto[]; +} diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index 2063b7a..436d698 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -68,4 +68,12 @@ export class UpdateBusinessDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @ApiPropertyOptional({ + example: false, + description: 'When true, services are priced per owner-defined class (e.g. car size)', + }) + @IsOptional() + @IsBoolean() + serviceClassesEnabled?: boolean; }