add business category

This commit is contained in:
2026-05-25 21:37:01 +03:30
parent c502fd86d7
commit f213ad2453
10 changed files with 494 additions and 0 deletions
+2
View File
@@ -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,
],
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateBusinessServiceRequests1789044000000
implements MigrationInterface
{
name = 'CreateBusinessServiceRequests1789044000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_requests"`);
}
}
@@ -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;
}
@@ -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<BusinessServiceRequestEntity> {
return this.service.create(user.id, payload);
}
@Get('mine')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.BUSINESS_OWNER)
findMine(
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceRequestEntity[]> {
return this.service.findMine(user.id);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
findAll(
@Query('status') status?: BusinessServiceRequestStatus,
): Promise<BusinessServiceRequestEntity[]> {
return this.service.findAll({ status });
}
@Get(':id')
@UseGuards(JwtAuthGuard)
findOne(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceRequestEntity> {
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<BusinessServiceRequestEntity> {
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<BusinessServiceRequestEntity> {
return this.service.reject(id, user, payload);
}
}
@@ -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 {}
@@ -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<BusinessServiceRequestEntity>,
@InjectRepository(BusinessEntity)
private readonly businessRepo: Repository<BusinessEntity>,
@InjectRepository(UserEntity)
private readonly userRepo: Repository<UserEntity>,
private readonly templatesService: ServiceTemplatesService,
private readonly businessServicesService: BusinessServicesService,
) {}
async create(
ownerId: string,
dto: CreateBusinessServiceRequestDto,
): Promise<BusinessServiceRequestEntity> {
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<BusinessServiceRequestEntity[]> {
return this.repo.find({
where: { requestedById: ownerId },
relations: { business: true, serviceTemplate: true, businessService: true },
order: { createdAt: 'DESC' },
});
}
findAll(filters: ListFilters): Promise<BusinessServiceRequestEntity[]> {
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<BusinessServiceRequestEntity> {
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<BusinessServiceRequestEntity> {
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<BusinessServiceRequestEntity> {
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();
}
}
@@ -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;
}
@@ -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;
}
@@ -18,5 +18,6 @@ import { BusinessServicesService } from './business-services.service';
],
controllers: [BusinessServicesController],
providers: [BusinessServicesService],
exports: [BusinessServicesService],
})
export class BusinessServicesModule {}
@@ -13,5 +13,6 @@ import { ServiceTemplatesService } from './service-templates.service';
],
controllers: [ServiceTemplatesController],
providers: [ServiceTemplatesService],
exports: [ServiceTemplatesService],
})
export class ServiceTemplatesModule {}