update rezerv
This commit is contained in:
@@ -13,6 +13,7 @@ import { BusinessAttributeValuesModule } from './modules/business-attribute-valu
|
|||||||
import { BookingsModule } from './modules/bookings/bookings.module';
|
import { BookingsModule } from './modules/bookings/bookings.module';
|
||||||
import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module';
|
import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module';
|
||||||
import { BusinessServiceRequestsModule } from './modules/business-service-requests/business-service-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 { AuthModule } from './modules/auth/auth.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
BookingsModule,
|
BookingsModule,
|
||||||
BusinessRegistrationRequestsModule,
|
BusinessRegistrationRequestsModule,
|
||||||
BusinessServiceRequestsModule,
|
BusinessServiceRequestsModule,
|
||||||
|
BusinessServiceClassesModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity';
|
import { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
|
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
@@ -20,6 +21,7 @@ export async function deleteBusinessCascade(
|
|||||||
await manager.delete(BusinessServiceRequestEntity, { businessId });
|
await manager.delete(BusinessServiceRequestEntity, { businessId });
|
||||||
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
|
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
|
||||||
await manager.delete(BusinessServiceEntity, { businessId });
|
await manager.delete(BusinessServiceEntity, { businessId });
|
||||||
|
await manager.delete(BusinessServiceClassEntity, { businessId });
|
||||||
await manager.delete(BusinessAttributeValueEntity, { businessId });
|
await manager.delete(BusinessAttributeValueEntity, { businessId });
|
||||||
await manager.delete(BusinessImageEntity, { businessId });
|
await manager.delete(BusinessImageEntity, { businessId });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateBusinessServiceClasses1789046000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreateBusinessServiceClasses1789046000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBusinessServiceClassesEnabled1789046100000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddBusinessServiceClassesEnabled1789046100000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "businesses"
|
||||||
|
ADD COLUMN IF NOT EXISTS "serviceClassesEnabled" boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "serviceClassesEnabled"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBookingGroupId1789046200000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingGroupId1789046200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_booking_group"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "bookingGroupId"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { BusinessEntity } from './business.entity';
|
import { BusinessEntity } from './business.entity';
|
||||||
import { BusinessServiceEntity } from './business-service.entity';
|
import { BusinessServiceEntity } from './business-service.entity';
|
||||||
|
import { BusinessServiceClassEntity } from './business-service-class.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
@@ -46,6 +47,16 @@ export class BookingEntity {
|
|||||||
@Column('uuid')
|
@Column('uuid')
|
||||||
businessServiceId!: string;
|
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' })
|
@Column({ type: 'timestamptz' })
|
||||||
scheduledAt!: Date;
|
scheduledAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { BookingEntity } from './booking.entity';
|
import { BookingEntity } from './booking.entity';
|
||||||
|
import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity';
|
||||||
import { BusinessEntity } from './business.entity';
|
import { BusinessEntity } from './business.entity';
|
||||||
import { ServiceTemplateEntity } from './service-template.entity';
|
import { ServiceTemplateEntity } from './service-template.entity';
|
||||||
|
|
||||||
@@ -49,6 +50,9 @@ export class BusinessServiceEntity {
|
|||||||
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
|
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
|
||||||
bookings!: BookingEntity[];
|
bookings!: BookingEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => BusinessServiceClassPriceEntity, (classPrice) => classPrice.businessService)
|
||||||
|
classPrices!: BusinessServiceClassPriceEntity[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity';
|
||||||
import { BusinessServiceEntity } from './business-service.entity';
|
import { BusinessServiceEntity } from './business-service.entity';
|
||||||
|
import { BusinessServiceClassEntity } from './business-service-class.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Entity('businesses')
|
@Entity('businesses')
|
||||||
@@ -45,6 +46,9 @@ export class BusinessEntity {
|
|||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
serviceClassesEnabled!: boolean;
|
||||||
|
|
||||||
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
|
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
|
||||||
onDelete: 'RESTRICT',
|
onDelete: 'RESTRICT',
|
||||||
})
|
})
|
||||||
@@ -72,6 +76,9 @@ export class BusinessEntity {
|
|||||||
@OneToMany(() => BusinessServiceEntity, (service) => service.business)
|
@OneToMany(() => BusinessServiceEntity, (service) => service.business)
|
||||||
services!: BusinessServiceEntity[];
|
services!: BusinessServiceEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.business)
|
||||||
|
serviceClasses!: BusinessServiceClassEntity[];
|
||||||
|
|
||||||
@OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business)
|
@OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business)
|
||||||
attributes!: BusinessAttributeValueEntity[];
|
attributes!: BusinessAttributeValueEntity[];
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
|
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -28,10 +29,20 @@ export class BookingsController {
|
|||||||
@Get('available-slots')
|
@Get('available-slots')
|
||||||
getAvailableSlots(
|
getAvailableSlots(
|
||||||
@Query('businessId') businessId: string,
|
@Query('businessId') businessId: string,
|
||||||
@Query('businessServiceId') businessServiceId: string,
|
|
||||||
@Query('date') date: 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()
|
@Get()
|
||||||
@@ -71,6 +82,16 @@ export class BookingsController {
|
|||||||
return this.service.create(payload, user);
|
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<BookingEntity[]> {
|
||||||
|
return this.service.createBatch(payload, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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 { 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 { 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 { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
@@ -18,6 +20,8 @@ import { BookingsService } from './bookings.service';
|
|||||||
BusinessEntity,
|
BusinessEntity,
|
||||||
BusinessServiceEntity,
|
BusinessServiceEntity,
|
||||||
BusinessAvailabilitySlotEntity,
|
BusinessAvailabilitySlotEntity,
|
||||||
|
BusinessServiceClassEntity,
|
||||||
|
BusinessServiceClassPriceEntity,
|
||||||
ServiceTemplateEntity,
|
ServiceTemplateEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
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 { randomUUID } from 'crypto';
|
||||||
|
import { In, Repository } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
generateAvailableSlotStarts,
|
generateAvailableSlotStarts,
|
||||||
parseGregorianYmd,
|
parseGregorianYmd,
|
||||||
@@ -17,11 +18,14 @@ import {
|
|||||||
} from '../../common/utils/booking-schedule.util';
|
} 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 { 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 { 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';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
|
|
||||||
type FindAllFilters = {
|
type FindAllFilters = {
|
||||||
@@ -47,6 +51,10 @@ export class BookingsService {
|
|||||||
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
|
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
|
||||||
@InjectRepository(BusinessAvailabilitySlotEntity)
|
@InjectRepository(BusinessAvailabilitySlotEntity)
|
||||||
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
||||||
|
@InjectRepository(BusinessServiceClassEntity)
|
||||||
|
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
|
||||||
|
@InjectRepository(BusinessServiceClassPriceEntity)
|
||||||
|
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||||
) {
|
) {
|
||||||
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;
|
||||||
@@ -63,6 +71,7 @@ export class BookingsService {
|
|||||||
.leftJoinAndSelect('b.business', 'business')
|
.leftJoinAndSelect('b.business', 'business')
|
||||||
.leftJoinAndSelect('b.businessService', 'businessService')
|
.leftJoinAndSelect('b.businessService', 'businessService')
|
||||||
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
|
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
|
||||||
|
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
|
||||||
.orderBy('b.scheduledAt', 'ASC');
|
.orderBy('b.scheduledAt', 'ASC');
|
||||||
|
|
||||||
if (actor?.role === UserRole.CUSTOMER) {
|
if (actor?.role === UserRole.CUSTOMER) {
|
||||||
@@ -108,6 +117,7 @@ export class BookingsService {
|
|||||||
customer: true,
|
customer: true,
|
||||||
business: true,
|
business: true,
|
||||||
businessService: { serviceTemplate: true },
|
businessService: { serviceTemplate: true },
|
||||||
|
serviceClass: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -151,21 +161,21 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||||
const durationMinutes = resolveServiceDurationMinutes(
|
const pricing = await this.resolveBookingPricing(
|
||||||
businessService.durationMinutes,
|
businessService,
|
||||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
dto.serviceClassId ?? null,
|
||||||
);
|
);
|
||||||
await this.assertSchedulableSlot({
|
await this.assertSchedulableSlot({
|
||||||
businessId: dto.businessId,
|
businessId: dto.businessId,
|
||||||
businessServiceId: dto.businessServiceId,
|
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
durationMinutes,
|
durationMinutes: pricing.durationMinutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const entity = this.repo.create({
|
const entity = this.repo.create({
|
||||||
customerId: dto.customerId,
|
customerId: dto.customerId,
|
||||||
businessId: dto.businessId,
|
businessId: dto.businessId,
|
||||||
businessServiceId: dto.businessServiceId,
|
businessServiceId: dto.businessServiceId,
|
||||||
|
serviceClassId: pricing.serviceClassId,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
note: dto.note ?? null,
|
note: dto.note ?? null,
|
||||||
status: BookingStatus.PENDING,
|
status: BookingStatus.PENDING,
|
||||||
@@ -174,6 +184,93 @@ export class BookingsService {
|
|||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createBatch(
|
||||||
|
dto: CreateBookingBatchDto,
|
||||||
|
actor?: AuthUser,
|
||||||
|
): Promise<BookingEntity[]> {
|
||||||
|
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(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateBookingDto,
|
dto: UpdateBookingDto,
|
||||||
@@ -185,18 +282,21 @@ export class BookingsService {
|
|||||||
|
|
||||||
if (dto.scheduledAt !== undefined) {
|
if (dto.scheduledAt !== undefined) {
|
||||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||||
const durationMinutes = resolveServiceDurationMinutes(
|
const durationMinutes = await this.resolveGroupDuration(row);
|
||||||
row.businessService?.durationMinutes,
|
|
||||||
row.businessService?.serviceTemplate?.defaultDurationMinutes,
|
|
||||||
);
|
|
||||||
await this.assertSchedulableSlot({
|
await this.assertSchedulableSlot({
|
||||||
businessId: row.businessId,
|
businessId: row.businessId,
|
||||||
businessServiceId: row.businessServiceId,
|
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
excludeBookingId: row.id,
|
excludeBookingId: row.id,
|
||||||
|
excludeBookingGroupId: row.bookingGroupId ?? undefined,
|
||||||
});
|
});
|
||||||
row.scheduledAt = scheduledAt;
|
row.scheduledAt = scheduledAt;
|
||||||
|
if (row.bookingGroupId) {
|
||||||
|
await this.repo.update(
|
||||||
|
{ bookingGroupId: row.bookingGroupId },
|
||||||
|
{ scheduledAt },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -240,9 +340,13 @@ export class BookingsService {
|
|||||||
|
|
||||||
async getAvailableSlots(
|
async getAvailableSlots(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
businessServiceId: string,
|
|
||||||
dateYmd: string,
|
dateYmd: string,
|
||||||
): Promise<{ date: string; slots: string[] }> {
|
options: {
|
||||||
|
businessServiceId?: string;
|
||||||
|
businessServiceIds?: string[];
|
||||||
|
serviceClassId?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
|
||||||
parseGregorianYmd(dateYmd);
|
parseGregorianYmd(dateYmd);
|
||||||
|
|
||||||
const business = await this.businessRepo.findOne({
|
const business = await this.businessRepo.findOne({
|
||||||
@@ -252,32 +356,49 @@ export class BookingsService {
|
|||||||
throw new NotFoundException('Business not found or inactive');
|
throw new NotFoundException('Business not found or inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
const businessService = await this.businessServiceRepo.findOne({
|
const serviceIds =
|
||||||
where: { id: businessServiceId, businessId, isActive: true },
|
options.businessServiceIds?.length
|
||||||
relations: { serviceTemplate: true },
|
? options.businessServiceIds
|
||||||
});
|
: options.businessServiceId
|
||||||
if (!businessService) {
|
? [options.businessServiceId]
|
||||||
throw new NotFoundException('Business service not found or inactive');
|
: [];
|
||||||
|
|
||||||
|
if (!serviceIds.length) {
|
||||||
|
throw new BadRequestException('At least one business service is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationMinutes = resolveServiceDurationMinutes(
|
const serviceClassId = options.serviceClassId ?? null;
|
||||||
businessService.durationMinutes,
|
let totalDurationMinutes = 0;
|
||||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
|
||||||
);
|
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 windows = await this.loadAvailabilityWindows(businessId);
|
||||||
const busy = await this.loadBusyIntervals(businessId, dateYmd);
|
const busy = await this.loadBusyIntervals(businessId, dateYmd);
|
||||||
|
|
||||||
const slots = generateAvailableSlotStarts({
|
const slots = generateAvailableSlotStarts({
|
||||||
dateYmd,
|
dateYmd,
|
||||||
durationMinutes,
|
durationMinutes: totalDurationMinutes,
|
||||||
leadMinutes: this.minLeadMinutes,
|
leadMinutes: this.minLeadMinutes,
|
||||||
slotStepMinutes: 15,
|
slotStepMinutes: 15,
|
||||||
windows,
|
windows,
|
||||||
busy,
|
busy,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { date: dateYmd, slots };
|
return { date: dateYmd, slots, totalDurationMinutes };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAvailabilityWindows(
|
private async loadAvailabilityWindows(
|
||||||
@@ -294,11 +415,20 @@ export class BookingsService {
|
|||||||
private async loadBusyIntervals(
|
private async loadBusyIntervals(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
dateYmd: string,
|
dateYmd: string,
|
||||||
excludeBookingId?: string,
|
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
|
||||||
): Promise<BusyInterval[]> {
|
): Promise<BusyInterval[]> {
|
||||||
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
|
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
|
||||||
const dayEnd = new Date(`${dateYmd}T23:59:59+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
|
const qb = this.repo
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.leftJoinAndSelect('b.businessService', 'bs')
|
.leftJoinAndSelect('b.businessService', 'bs')
|
||||||
@@ -310,34 +440,202 @@ export class BookingsService {
|
|||||||
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
|
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
|
||||||
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
||||||
|
|
||||||
if (excludeBookingId) {
|
if (options?.excludeBookingId && !excludeGroupId) {
|
||||||
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
|
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 bookings = await qb.getMany();
|
||||||
|
const durations = await Promise.all(
|
||||||
|
bookings.map((b) => this.resolveBookingDuration(b)),
|
||||||
|
);
|
||||||
|
|
||||||
return bookings.map((b) => {
|
const groupTotals = new Map<
|
||||||
const duration = resolveServiceDurationMinutes(
|
string,
|
||||||
b.businessService?.durationMinutes,
|
{ startMs: number; totalDuration: number }
|
||||||
b.businessService?.serviceTemplate?.defaultDurationMinutes,
|
>();
|
||||||
);
|
const singles: BusyInterval[] = [];
|
||||||
const startMs = b.scheduledAt.getTime();
|
|
||||||
return {
|
for (const [index, booking] of bookings.entries()) {
|
||||||
startMs,
|
const duration = durations[index] ?? 30;
|
||||||
endMs: startMs + duration * 60 * 1000,
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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: {
|
private async assertSchedulableSlot(params: {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
businessServiceId: string;
|
|
||||||
scheduledAt: Date;
|
scheduledAt: Date;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
excludeBookingId?: string;
|
excludeBookingId?: string;
|
||||||
|
excludeBookingGroupId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { businessId, scheduledAt, durationMinutes, excludeBookingId } =
|
const {
|
||||||
params;
|
businessId,
|
||||||
|
scheduledAt,
|
||||||
|
durationMinutes,
|
||||||
|
excludeBookingId,
|
||||||
|
excludeBookingGroupId,
|
||||||
|
} = params;
|
||||||
|
|
||||||
this.assertLeadTime(scheduledAt);
|
this.assertLeadTime(scheduledAt);
|
||||||
|
|
||||||
@@ -355,11 +653,10 @@ export class BookingsService {
|
|||||||
leadMinutes: this.minLeadMinutes,
|
leadMinutes: this.minLeadMinutes,
|
||||||
slotStepMinutes: 15,
|
slotStepMinutes: 15,
|
||||||
windows,
|
windows,
|
||||||
busy: await this.loadBusyIntervals(
|
busy: await this.loadBusyIntervals(businessId, dateYmd, {
|
||||||
businessId,
|
|
||||||
dateYmd,
|
|
||||||
excludeBookingId,
|
excludeBookingId,
|
||||||
),
|
excludeBookingGroupId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduledMs = scheduledAt.getTime();
|
const scheduledMs = scheduledAt.getTime();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
businessServiceId!: string;
|
businessServiceId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
serviceClassId?: string | null;
|
||||||
|
|
||||||
/** ISO 8601 datetime string */
|
/** ISO 8601 datetime string */
|
||||||
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
|
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
|||||||
@@ -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<BusinessServiceClassEntity[]> {
|
||||||
|
const parsedIsActive =
|
||||||
|
typeof isActive === 'string' ? isActive === 'true' : undefined;
|
||||||
|
|
||||||
|
return this.service.findAll({ businessId, isActive: parsedIsActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string): Promise<BusinessServiceClassEntity> {
|
||||||
|
return this.service.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||||
|
create(
|
||||||
|
@Body() payload: CreateBusinessServiceClassDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
): Promise<BusinessServiceClassEntity> {
|
||||||
|
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<BusinessServiceClassEntity> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<BusinessServiceClassEntity>,
|
||||||
|
@InjectRepository(BusinessEntity)
|
||||||
|
private readonly businessRepo: Repository<BusinessEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(filters: FindAllFilters): Promise<BusinessServiceClassEntity[]> {
|
||||||
|
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<BusinessServiceClassEntity> {
|
||||||
|
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<BusinessServiceClassEntity> {
|
||||||
|
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<BusinessServiceClassEntity> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -19,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
|||||||
import { BusinessServicesService } from './business-services.service';
|
import { BusinessServicesService } from './business-services.service';
|
||||||
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
||||||
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
||||||
|
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
|
||||||
|
|
||||||
@Controller('business-services')
|
@Controller('business-services')
|
||||||
export class BusinessServicesController {
|
export class BusinessServicesController {
|
||||||
@@ -66,6 +68,17 @@ export class BusinessServicesController {
|
|||||||
return this.service.update(id, payload, user);
|
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<BusinessServiceEntity> {
|
||||||
|
return this.service.setClassPrices(id, payload, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.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 { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BusinessServicesController } from './business-services.controller';
|
import { BusinessServicesController } from './business-services.controller';
|
||||||
@@ -13,6 +15,8 @@ import { BusinessServicesService } from './business-services.service';
|
|||||||
BusinessServiceEntity,
|
BusinessServiceEntity,
|
||||||
BusinessEntity,
|
BusinessEntity,
|
||||||
ServiceTemplateEntity,
|
ServiceTemplateEntity,
|
||||||
|
BusinessServiceClassEntity,
|
||||||
|
BusinessServiceClassPriceEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.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 { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
import { UserRole } from '../../entities/user.entity';
|
import { UserRole } from '../../entities/user.entity';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
||||||
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
||||||
|
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
|
||||||
|
|
||||||
type FindAllFilters = {
|
type FindAllFilters = {
|
||||||
businessId?: string;
|
businessId?: string;
|
||||||
@@ -37,6 +40,10 @@ export class BusinessServicesService {
|
|||||||
private readonly businessRepo: Repository<BusinessEntity>,
|
private readonly businessRepo: Repository<BusinessEntity>,
|
||||||
@InjectRepository(ServiceTemplateEntity)
|
@InjectRepository(ServiceTemplateEntity)
|
||||||
private readonly templateRepo: Repository<ServiceTemplateEntity>,
|
private readonly templateRepo: Repository<ServiceTemplateEntity>,
|
||||||
|
@InjectRepository(BusinessServiceClassEntity)
|
||||||
|
private readonly classRepo: Repository<BusinessServiceClassEntity>,
|
||||||
|
@InjectRepository(BusinessServiceClassPriceEntity)
|
||||||
|
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filters: FindAllFilters): Promise<BusinessServiceEntity[]> {
|
async findAll(filters: FindAllFilters): Promise<BusinessServiceEntity[]> {
|
||||||
@@ -44,6 +51,8 @@ export class BusinessServicesService {
|
|||||||
.createQueryBuilder('bs')
|
.createQueryBuilder('bs')
|
||||||
.leftJoinAndSelect('bs.business', 'business')
|
.leftJoinAndSelect('bs.business', 'business')
|
||||||
.leftJoinAndSelect('bs.serviceTemplate', 'template')
|
.leftJoinAndSelect('bs.serviceTemplate', 'template')
|
||||||
|
.leftJoinAndSelect('bs.classPrices', 'classPrices')
|
||||||
|
.leftJoinAndSelect('classPrices.serviceClass', 'serviceClass')
|
||||||
.orderBy('bs.createdAt', 'DESC');
|
.orderBy('bs.createdAt', 'DESC');
|
||||||
|
|
||||||
if (filters.businessId) {
|
if (filters.businessId) {
|
||||||
@@ -68,7 +77,11 @@ export class BusinessServicesService {
|
|||||||
async findOne(id: string): Promise<BusinessServiceEntity> {
|
async findOne(id: string): Promise<BusinessServiceEntity> {
|
||||||
const row = await this.repo.findOne({
|
const row = await this.repo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: { business: true, serviceTemplate: true },
|
relations: {
|
||||||
|
business: true,
|
||||||
|
serviceTemplate: true,
|
||||||
|
classPrices: { serviceClass: true },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Business service not found');
|
throw new NotFoundException('Business service not found');
|
||||||
@@ -193,6 +206,67 @@ export class BusinessServicesService {
|
|||||||
return this.repo.save(row);
|
return this.repo.save(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setClassPrices(
|
||||||
|
id: string,
|
||||||
|
dto: SetBusinessServiceClassPricesDto,
|
||||||
|
actor?: AuthUser,
|
||||||
|
): Promise<BusinessServiceEntity> {
|
||||||
|
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<void> {
|
async remove(id: string, actor?: AuthUser): Promise<void> {
|
||||||
const row = await this.findOne(id);
|
const row = await this.findOne(id);
|
||||||
const business = await this.businessRepo.findOne({
|
const business = await this.businessRepo.findOne({
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -68,4 +68,12 @@ export class UpdateBusinessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: false,
|
||||||
|
description: 'When true, services are priced per owner-defined class (e.g. car size)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
serviceClassesEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user