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
@@ -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 {}