diff --git a/src/app.module.ts b/src/app.module.ts index da6668b..716af22 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { dataSourceOptions } from './config/typeorm.config'; import { BusinessCategoriesModule } from './modules/business-categories/business-categories.module'; import { BusinessesModule } from './modules/businesses/businesses.module'; import { ServiceTemplatesModule } from './modules/service-templates/service-templates.module'; +import { BusinessServicesModule } from './modules/business-services/business-services.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { ServiceTemplatesModule } from './modules/service-templates/service-temp BusinessCategoriesModule, BusinessesModule, ServiceTemplatesModule, + BusinessServicesModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/modules/business-services/business-services.controller.ts b/src/modules/business-services/business-services.controller.ts new file mode 100644 index 0000000..bd8d518 --- /dev/null +++ b/src/modules/business-services/business-services.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { BusinessServicesService } from './business-services.service'; +import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; +import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; + +@Controller('business-services') +export class BusinessServicesController { + constructor(private readonly service: BusinessServicesService) {} + + @Get() + findAll( + @Query('businessId') businessId?: string, + @Query('serviceTemplateId') serviceTemplateId?: string, + @Query('isActive') isActive?: string, + ): Promise { + const parsedIsActive = + typeof isActive === 'string' ? isActive === 'true' : undefined; + + return this.service.findAll({ + businessId, + serviceTemplateId, + isActive: parsedIsActive, + }); + } + + @Get(':id') + findOne(@Param('id') id: string): Promise { + return this.service.findOne(id); + } + + @Post() + create( + @Body() payload: CreateBusinessServiceDto, + ): Promise { + return this.service.create(payload); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() payload: UpdateBusinessServiceDto, + ): Promise { + return this.service.update(id, payload); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise<{ success: true }> { + await this.service.remove(id); + return { success: true }; + } +} diff --git a/src/modules/business-services/business-services.module.ts b/src/modules/business-services/business-services.module.ts new file mode 100644 index 0000000..94183e3 --- /dev/null +++ b/src/modules/business-services/business-services.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BusinessEntity } from '../../entities/business.entity'; +import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { ServiceTemplateEntity } from '../../entities/service-template.entity'; +import { BusinessServicesController } from './business-services.controller'; +import { BusinessServicesService } from './business-services.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + BusinessServiceEntity, + BusinessEntity, + ServiceTemplateEntity, + ]), + ], + controllers: [BusinessServicesController], + providers: [BusinessServicesService], +}) +export class BusinessServicesModule {} diff --git a/src/modules/business-services/business-services.service.ts b/src/modules/business-services/business-services.service.ts new file mode 100644 index 0000000..559b13d --- /dev/null +++ b/src/modules/business-services/business-services.service.ts @@ -0,0 +1,193 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BusinessEntity } from '../../entities/business.entity'; +import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { ServiceTemplateEntity } from '../../entities/service-template.entity'; +import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; +import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; + +type FindAllFilters = { + businessId?: string; + serviceTemplateId?: string; + isActive?: boolean; +}; + +function normalizePrice(value: number): string { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException('price must be a non-negative finite number'); + } + return value.toFixed(2); +} + +@Injectable() +export class BusinessServicesService { + constructor( + @InjectRepository(BusinessServiceEntity) + private readonly repo: Repository, + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + @InjectRepository(ServiceTemplateEntity) + private readonly templateRepo: Repository, + ) {} + + async findAll(filters: FindAllFilters): Promise { + const query = this.repo + .createQueryBuilder('bs') + .leftJoinAndSelect('bs.business', 'business') + .leftJoinAndSelect('bs.serviceTemplate', 'template') + .orderBy('bs.createdAt', 'DESC'); + + if (filters.businessId) { + query.andWhere('bs.businessId = :businessId', { + businessId: filters.businessId, + }); + } + if (filters.serviceTemplateId) { + query.andWhere('bs.serviceTemplateId = :serviceTemplateId', { + serviceTemplateId: filters.serviceTemplateId, + }); + } + if (typeof filters.isActive === 'boolean') { + query.andWhere('bs.isActive = :isActive', { + isActive: filters.isActive, + }); + } + + return query.getMany(); + } + + async findOne(id: string): Promise { + const row = await this.repo.findOne({ + where: { id }, + relations: { business: true, serviceTemplate: true }, + }); + if (!row) { + throw new NotFoundException('Business service not found'); + } + return row; + } + + async create(dto: CreateBusinessServiceDto): Promise { + const business = await this.businessRepo.findOne({ + where: { id: dto.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + const template = await this.templateRepo.findOne({ + where: { id: dto.serviceTemplateId }, + }); + if (!template) { + throw new NotFoundException('Service template not found'); + } + + if (template.categoryId !== business.categoryId) { + throw new BadRequestException( + 'Service template category must match business category', + ); + } + + const dup = await this.repo.findOne({ + where: { + businessId: dto.businessId, + serviceTemplateId: dto.serviceTemplateId, + }, + }); + if (dup) { + throw new ConflictException( + 'This service template is already enabled for this business', + ); + } + + const duration = + dto.durationMinutes ?? template.defaultDurationMinutes ?? null; + + const entity = this.repo.create({ + businessId: dto.businessId, + serviceTemplateId: dto.serviceTemplateId, + price: normalizePrice(dto.price), + durationMinutes: duration, + isActive: dto.isActive ?? true, + }); + + return this.repo.save(entity); + } + + async update( + id: string, + dto: UpdateBusinessServiceDto, + ): Promise { + const row = await this.findOne(id); + + let nextTemplateId = row.serviceTemplateId; + if (dto.serviceTemplateId !== undefined) { + nextTemplateId = dto.serviceTemplateId; + } + + const business = await this.businessRepo.findOne({ + where: { id: row.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + const template = await this.templateRepo.findOne({ + where: { id: nextTemplateId }, + }); + if (!template) { + throw new NotFoundException('Service template not found'); + } + + if (template.categoryId !== business.categoryId) { + throw new BadRequestException( + 'Service template category must match business category', + ); + } + + if ( + dto.serviceTemplateId !== undefined && + dto.serviceTemplateId !== row.serviceTemplateId + ) { + const dup = await this.repo.findOne({ + where: { + businessId: row.businessId, + serviceTemplateId: dto.serviceTemplateId, + }, + }); + if (dup && dup.id !== row.id) { + throw new ConflictException( + 'This service template is already enabled for this business', + ); + } + } + + if (dto.price !== undefined) { + row.price = normalizePrice(dto.price); + } + if (dto.durationMinutes !== undefined) { + row.durationMinutes = dto.durationMinutes; + } + if (dto.isActive !== undefined) { + row.isActive = dto.isActive; + } + if (dto.serviceTemplateId !== undefined) { + row.serviceTemplateId = dto.serviceTemplateId; + } + + return this.repo.save(row); + } + + async remove(id: string): Promise { + const result = await this.repo.delete(id); + if (!result.affected) { + throw new NotFoundException('Business service not found'); + } + } +} diff --git a/src/modules/business-services/dto/create-business-service.dto.ts b/src/modules/business-services/dto/create-business-service.dto.ts new file mode 100644 index 0000000..95c78e0 --- /dev/null +++ b/src/modules/business-services/dto/create-business-service.dto.ts @@ -0,0 +1,9 @@ +export class CreateBusinessServiceDto { + businessId!: string; + serviceTemplateId!: string; + /** قیمت به واحد پول (عدد اعشاری مجاز است) */ + price!: number; + /** اگر خالی باشد از template.defaultDurationMinutes استفاده می‌شود */ + durationMinutes?: number | null; + isActive?: boolean; +} diff --git a/src/modules/business-services/dto/update-business-service.dto.ts b/src/modules/business-services/dto/update-business-service.dto.ts new file mode 100644 index 0000000..d9755a3 --- /dev/null +++ b/src/modules/business-services/dto/update-business-service.dto.ts @@ -0,0 +1,6 @@ +export class UpdateBusinessServiceDto { + serviceTemplateId?: string; + price?: number; + durationMinutes?: number | null; + isActive?: boolean; +}