diff --git a/src/app.module.ts b/src/app.module.ts index cc7ecf5..30a2699 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { AttributeDefinitionsModule } from './modules/attribute-definitions/attr import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module'; 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 { AuthModule } from './modules/auth/auth.module'; import { UsersModule } from './modules/users/users.module'; @@ -27,6 +28,7 @@ import { UsersModule } from './modules/users/users.module'; BusinessAttributeValuesModule, BookingsModule, BusinessRegistrationRequestsModule, + BusinessServiceRequestsModule, AuthModule, UsersModule, ], diff --git a/src/database/migrations/1789044000000-CreateBusinessServiceRequests.ts b/src/database/migrations/1789044000000-CreateBusinessServiceRequests.ts new file mode 100644 index 0000000..f6e6a5c --- /dev/null +++ b/src/database/migrations/1789044000000-CreateBusinessServiceRequests.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateBusinessServiceRequests1789044000000 + implements MigrationInterface +{ + name = 'CreateBusinessServiceRequests1789044000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "business_service_requests" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "businessId" uuid NOT NULL, + "requestedById" uuid NOT NULL, + "status" varchar(20) NOT NULL DEFAULT 'pending', + "title" varchar(150) NOT NULL, + "description" varchar(500), + "price" numeric(12,2) NOT NULL, + "durationMinutes" integer, + "adminNote" varchar(500), + "reviewedById" uuid, + "reviewedAt" TIMESTAMPTZ, + "serviceTemplateId" uuid, + "businessServiceId" uuid, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_business_service_requests" PRIMARY KEY ("id"), + CONSTRAINT "FK_bsr_business" FOREIGN KEY ("businessId") + REFERENCES "businesses"("id") ON DELETE CASCADE, + CONSTRAINT "FK_bsr_requested_by" FOREIGN KEY ("requestedById") + REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_bsr_reviewed_by" FOREIGN KEY ("reviewedById") + REFERENCES "users"("id") ON DELETE SET NULL, + CONSTRAINT "FK_bsr_template" FOREIGN KEY ("serviceTemplateId") + REFERENCES "service_templates"("id") ON DELETE SET NULL, + CONSTRAINT "FK_bsr_business_service" FOREIGN KEY ("businessServiceId") + REFERENCES "business_services"("id") ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_bsr_business_status" + ON "business_service_requests" ("businessId", "status") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "business_service_requests"`); + } +} diff --git a/src/entities/business-service-request.entity.ts b/src/entities/business-service-request.entity.ts new file mode 100644 index 0000000..b9d1efc --- /dev/null +++ b/src/entities/business-service-request.entity.ts @@ -0,0 +1,91 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { BusinessEntity } from './business.entity'; +import { BusinessServiceEntity } from './business-service.entity'; +import { ServiceTemplateEntity } from './service-template.entity'; +import { UserEntity } from './user.entity'; + +export enum BusinessServiceRequestStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +@Entity('business_service_requests') +export class BusinessServiceRequestEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BusinessEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'businessId' }) + business!: BusinessEntity; + + @Column('uuid') + businessId!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'requestedById' }) + requestedBy!: UserEntity; + + @Column('uuid') + requestedById!: string; + + @Column({ + type: 'varchar', + length: 20, + default: BusinessServiceRequestStatus.PENDING, + }) + status!: BusinessServiceRequestStatus; + + @Column({ length: 150 }) + title!: string; + + @Column({ type: 'varchar', nullable: true, length: 500 }) + description!: string | null; + + @Column({ type: 'numeric', precision: 12, scale: 2 }) + price!: string; + + @Column({ type: 'int', nullable: true }) + durationMinutes!: number | null; + + @Column({ type: 'varchar', nullable: true, length: 500 }) + adminNote!: string | null; + + @ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'reviewedById' }) + reviewedBy!: UserEntity | null; + + @Column({ type: 'uuid', nullable: true }) + reviewedById!: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + reviewedAt!: Date | null; + + @ManyToOne(() => ServiceTemplateEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'serviceTemplateId' }) + serviceTemplate!: ServiceTemplateEntity | null; + + @Column({ type: 'uuid', nullable: true }) + serviceTemplateId!: string | null; + + @ManyToOne(() => BusinessServiceEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'businessServiceId' }) + businessService!: BusinessServiceEntity | null; + + @Column({ type: 'uuid', nullable: true }) + businessServiceId!: string | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/modules/business-service-requests/business-service-requests.controller.ts b/src/modules/business-service-requests/business-service-requests.controller.ts new file mode 100644 index 0000000..ef68c40 --- /dev/null +++ b/src/modules/business-service-requests/business-service-requests.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + BusinessServiceRequestEntity, + BusinessServiceRequestStatus, +} from '../../entities/business-service-request.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 { BusinessServiceRequestsService } from './business-service-requests.service'; +import { CreateBusinessServiceRequestDto } from './dto/create-business-service-request.dto'; +import { RejectBusinessServiceRequestDto } from './dto/reject-business-service-request.dto'; + +@Controller('business-service-requests') +export class BusinessServiceRequestsController { + constructor(private readonly service: BusinessServiceRequestsService) {} + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + create( + @CurrentUser() user: AuthUser, + @Body() payload: CreateBusinessServiceRequestDto, + ): Promise { + return this.service.create(user.id, payload); + } + + @Get('mine') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + findMine( + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.findMine(user.id); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + findAll( + @Query('status') status?: BusinessServiceRequestStatus, + ): Promise { + return this.service.findAll({ status }); + } + + @Get(':id') + @UseGuards(JwtAuthGuard) + findOne( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.findOne(id).then((entity) => { + this.service.assertCanView(entity, user); + return entity; + }); + } + + @Post(':id/approve') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + approve( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.approve(id, user); + } + + @Post(':id/reject') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + reject( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + @Body() payload: RejectBusinessServiceRequestDto, + ): Promise { + return this.service.reject(id, user, payload); + } +} diff --git a/src/modules/business-service-requests/business-service-requests.module.ts b/src/modules/business-service-requests/business-service-requests.module.ts new file mode 100644 index 0000000..76cbc8f --- /dev/null +++ b/src/modules/business-service-requests/business-service-requests.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity'; +import { BusinessEntity } from '../../entities/business.entity'; +import { UserEntity } from '../../entities/user.entity'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessServicesModule } from '../business-services/business-services.module'; +import { ServiceTemplatesModule } from '../service-templates/service-templates.module'; +import { BusinessServiceRequestsController } from './business-service-requests.controller'; +import { BusinessServiceRequestsService } from './business-service-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + BusinessServiceRequestEntity, + BusinessEntity, + UserEntity, + ]), + AuthModule, + ServiceTemplatesModule, + BusinessServicesModule, + ], + controllers: [BusinessServiceRequestsController], + providers: [BusinessServiceRequestsService], +}) +export class BusinessServiceRequestsModule {} diff --git a/src/modules/business-service-requests/business-service-requests.service.ts b/src/modules/business-service-requests/business-service-requests.service.ts new file mode 100644 index 0000000..11a73ad --- /dev/null +++ b/src/modules/business-service-requests/business-service-requests.service.ts @@ -0,0 +1,188 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + BusinessServiceRequestEntity, + BusinessServiceRequestStatus, +} from '../../entities/business-service-request.entity'; +import { BusinessEntity } from '../../entities/business.entity'; +import { UserEntity, UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { BusinessServicesService } from '../business-services/business-services.service'; +import { ServiceTemplatesService } from '../service-templates/service-templates.service'; +import { CreateBusinessServiceRequestDto } from './dto/create-business-service-request.dto'; +import { RejectBusinessServiceRequestDto } from './dto/reject-business-service-request.dto'; + +type ListFilters = { + status?: BusinessServiceRequestStatus; +}; + +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 BusinessServiceRequestsService { + constructor( + @InjectRepository(BusinessServiceRequestEntity) + private readonly repo: Repository, + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + @InjectRepository(UserEntity) + private readonly userRepo: Repository, + private readonly templatesService: ServiceTemplatesService, + private readonly businessServicesService: BusinessServicesService, + ) {} + + async create( + ownerId: string, + dto: CreateBusinessServiceRequestDto, + ): Promise { + const business = await this.businessRepo.findOne({ where: { ownerId } }); + if (!business) { + throw new BadRequestException('No business linked to your account'); + } + + const pending = await this.repo.findOne({ + where: { + businessId: business.id, + title: dto.title.trim(), + status: BusinessServiceRequestStatus.PENDING, + }, + }); + if (pending) { + throw new ConflictException('You already have a pending request with this title'); + } + + const entity = this.repo.create({ + businessId: business.id, + requestedById: ownerId, + status: BusinessServiceRequestStatus.PENDING, + title: dto.title.trim(), + description: dto.description?.trim() ?? null, + price: normalizePrice(dto.price), + durationMinutes: dto.durationMinutes ?? null, + }); + + const saved = await this.repo.save(entity); + return this.findOne(saved.id); + } + + findMine(ownerId: string): Promise { + return this.repo.find({ + where: { requestedById: ownerId }, + relations: { business: true, serviceTemplate: true, businessService: true }, + order: { createdAt: 'DESC' }, + }); + } + + findAll(filters: ListFilters): Promise { + const qb = this.repo + .createQueryBuilder('req') + .leftJoinAndSelect('req.business', 'business') + .leftJoinAndSelect('req.requestedBy', 'requestedBy') + .leftJoinAndSelect('req.serviceTemplate', 'serviceTemplate') + .orderBy('req.createdAt', 'DESC'); + + if (filters.status) { + qb.andWhere('req.status = :status', { status: filters.status }); + } + + return qb.getMany(); + } + + async findOne(id: string): Promise { + const entity = await this.repo.findOne({ + where: { id }, + relations: { + business: true, + requestedBy: true, + reviewedBy: true, + serviceTemplate: true, + businessService: true, + }, + }); + if (!entity) { + throw new NotFoundException('Service request not found'); + } + return entity; + } + + async approve(id: string, admin: AuthUser): Promise { + const request = await this.findOne(id); + if (request.status !== BusinessServiceRequestStatus.PENDING) { + throw new BadRequestException('Only pending requests can be approved'); + } + + const business = await this.businessRepo.findOne({ + where: { id: request.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + const template = await this.templatesService.create({ + title: request.title, + categoryId: business.categoryId, + description: request.description ?? undefined, + defaultDurationMinutes: request.durationMinutes ?? undefined, + isActive: true, + }); + + const price = Number.parseFloat(request.price); + const service = await this.businessServicesService.create( + { + businessId: request.businessId, + serviceTemplateId: template.id, + price, + durationMinutes: request.durationMinutes ?? undefined, + isActive: true, + }, + admin, + ); + + request.status = BusinessServiceRequestStatus.APPROVED; + request.serviceTemplateId = template.id; + request.businessServiceId = service.id; + request.reviewedById = admin.id; + request.reviewedAt = new Date(); + request.adminNote = null; + + const saved = await this.repo.save(request); + return this.findOne(saved.id); + } + + async reject( + id: string, + admin: AuthUser, + dto: RejectBusinessServiceRequestDto, + ): Promise { + const request = await this.findOne(id); + if (request.status !== BusinessServiceRequestStatus.PENDING) { + throw new BadRequestException('Only pending requests can be rejected'); + } + + request.status = BusinessServiceRequestStatus.REJECTED; + request.adminNote = dto.adminNote?.trim() ?? null; + request.reviewedById = admin.id; + request.reviewedAt = new Date(); + + const saved = await this.repo.save(request); + return this.findOne(saved.id); + } + + assertCanView(request: BusinessServiceRequestEntity, user: AuthUser): void { + if (user.role === UserRole.ADMIN) return; + if (request.requestedById === user.id) return; + throw new ForbiddenException(); + } +} diff --git a/src/modules/business-service-requests/dto/create-business-service-request.dto.ts b/src/modules/business-service-requests/dto/create-business-service-request.dto.ts new file mode 100644 index 0000000..daec9f3 --- /dev/null +++ b/src/modules/business-service-requests/dto/create-business-service-request.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateBusinessServiceRequestDto { + @ApiProperty({ example: 'رنگ موی تخصصی' }) + @IsString() + @MinLength(2) + @MaxLength(150) + title!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiProperty({ example: 250000 }) + @Type(() => Number) + @IsNumber() + @Min(0) + price!: number; + + @ApiPropertyOptional({ example: 45 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(24 * 60) + durationMinutes?: number; +} diff --git a/src/modules/business-service-requests/dto/reject-business-service-request.dto.ts b/src/modules/business-service-requests/dto/reject-business-service-request.dto.ts new file mode 100644 index 0000000..9576336 --- /dev/null +++ b/src/modules/business-service-requests/dto/reject-business-service-request.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class RejectBusinessServiceRequestDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + adminNote?: string; +} diff --git a/src/modules/business-services/business-services.module.ts b/src/modules/business-services/business-services.module.ts index 9e7a114..63e8809 100644 --- a/src/modules/business-services/business-services.module.ts +++ b/src/modules/business-services/business-services.module.ts @@ -18,5 +18,6 @@ import { BusinessServicesService } from './business-services.service'; ], controllers: [BusinessServicesController], providers: [BusinessServicesService], + exports: [BusinessServicesService], }) export class BusinessServicesModule {} diff --git a/src/modules/service-templates/service-templates.module.ts b/src/modules/service-templates/service-templates.module.ts index 729c394..5819187 100644 --- a/src/modules/service-templates/service-templates.module.ts +++ b/src/modules/service-templates/service-templates.module.ts @@ -13,5 +13,6 @@ import { ServiceTemplatesService } from './service-templates.service'; ], controllers: [ServiceTemplatesController], providers: [ServiceTemplatesService], + exports: [ServiceTemplatesService], }) export class ServiceTemplatesModule {}