Add business registration requests with approval flow, SMS notifications, and owner businesses endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 18:13:09 +03:30
co-authored by Cursor
parent 97a9ee022f
commit aa990c3204
14 changed files with 611 additions and 3 deletions
+1 -1
View File
@@ -27,6 +27,6 @@ import { RolesGuard } from './guards/roles.guard';
],
controllers: [AuthController],
providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard],
exports: [JwtAuthGuard, RolesGuard, JwtModule],
exports: [JwtAuthGuard, RolesGuard, JwtModule, SmsService],
})
export class AuthModule {}
+1 -1
View File
@@ -37,7 +37,7 @@ export class SmsService {
}
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const text = `کد ورود شما: ${code}`;
const text = `کد ورود منتایم: ${code}`;
return this.sendSms(mobile, text);
}
@@ -0,0 +1,90 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
BusinessRegistrationRequestEntity,
BusinessRegistrationStatus,
} from '../../entities/business-registration-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 { BusinessRegistrationRequestsService } from './business-registration-requests.service';
import { CreateBusinessRegistrationDto } from './dto/create-business-registration.dto';
import { RejectBusinessRegistrationDto } from './dto/reject-business-registration.dto';
@Controller('business-registration-requests')
export class BusinessRegistrationRequestsController {
constructor(private readonly service: BusinessRegistrationRequestsService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CUSTOMER)
create(
@CurrentUser() user: AuthUser,
@Body() payload: CreateBusinessRegistrationDto,
): Promise<BusinessRegistrationRequestEntity> {
return this.service.create(user.id, payload);
}
@Get('mine')
@UseGuards(JwtAuthGuard)
findMine(
@CurrentUser() user: AuthUser,
): Promise<BusinessRegistrationRequestEntity[]> {
return this.service.findMine(user.id);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
findAll(
@Query('status') status?: BusinessRegistrationStatus,
): Promise<BusinessRegistrationRequestEntity[]> {
return this.service.findAll({ status });
}
@Get(':id')
@UseGuards(JwtAuthGuard)
findOne(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
): Promise<BusinessRegistrationRequestEntity> {
return this.service.findOne(id).then((entity) => {
if (user.role !== UserRole.ADMIN && entity.applicantId !== user.id) {
throw new ForbiddenException();
}
return entity;
});
}
@Post(':id/approve')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
approve(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
): Promise<BusinessRegistrationRequestEntity> {
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: RejectBusinessRegistrationDto,
): Promise<BusinessRegistrationRequestEntity> {
return this.service.reject(id, user, payload);
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessRegistrationRequestEntity } from '../../entities/business-registration-request.entity';
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity } from '../../entities/user.entity';
import { AuthModule } from '../auth/auth.module';
import { BusinessRegistrationRequestsController } from './business-registration-requests.controller';
import { BusinessRegistrationRequestsService } from './business-registration-requests.service';
@Module({
imports: [
TypeOrmModule.forFeature([
BusinessRegistrationRequestEntity,
BusinessEntity,
BusinessCategoryEntity,
UserEntity,
]),
AuthModule,
],
controllers: [BusinessRegistrationRequestsController],
providers: [BusinessRegistrationRequestsService],
})
export class BusinessRegistrationRequestsModule {}
@@ -0,0 +1,253 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { uniqueBusinessSlug } from '../../common/utils/slug.util';
import {
BusinessRegistrationRequestEntity,
BusinessRegistrationStatus,
} from '../../entities/business-registration-request.entity';
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity, UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { SmsService } from '../auth/sms.service';
import { CreateBusinessRegistrationDto } from './dto/create-business-registration.dto';
import { RejectBusinessRegistrationDto } from './dto/reject-business-registration.dto';
type ListFilters = {
status?: BusinessRegistrationStatus;
};
@Injectable()
export class BusinessRegistrationRequestsService {
constructor(
@InjectRepository(BusinessRegistrationRequestEntity)
private readonly repo: Repository<BusinessRegistrationRequestEntity>,
@InjectRepository(BusinessEntity)
private readonly businessRepo: Repository<BusinessEntity>,
@InjectRepository(BusinessCategoryEntity)
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
@InjectRepository(UserEntity)
private readonly userRepo: Repository<UserEntity>,
private readonly sms: SmsService,
private readonly config: ConfigService,
) {}
async create(
applicantId: string,
dto: CreateBusinessRegistrationDto,
): Promise<BusinessRegistrationRequestEntity> {
const applicant = await this.userRepo.findOne({ where: { id: applicantId } });
if (!applicant) {
throw new NotFoundException('User not found');
}
if (applicant.role === UserRole.ADMIN) {
throw new ForbiddenException('Admin cannot submit business registration');
}
if (applicant.role === UserRole.BUSINESS_OWNER) {
throw new BadRequestException('You already manage a business');
}
const owned = await this.businessRepo.findOne({ where: { ownerId: applicantId } });
if (owned) {
throw new BadRequestException('You already have a registered business');
}
const pending = await this.repo.findOne({
where: {
applicantId,
status: BusinessRegistrationStatus.PENDING,
},
});
if (pending) {
throw new ConflictException('You already have a pending registration request');
}
await this.ensureCategoryExists(dto.categoryId);
const slug = await uniqueBusinessSlug(dto.title, async (candidate) => {
const inBusiness = await this.businessRepo.findOne({ where: { slug: candidate } });
if (inBusiness) return true;
const inRequest = await this.repo.findOne({ where: { slug: candidate } });
return Boolean(inRequest);
});
const entity = this.repo.create({
applicantId,
status: BusinessRegistrationStatus.PENDING,
title: dto.title.trim(),
slug,
categoryId: dto.categoryId,
description: dto.description?.trim() ?? null,
contactPhone: dto.contactPhone?.trim() ?? null,
address: dto.address?.trim() ?? null,
latitude: dto.latitude ?? null,
longitude: dto.longitude ?? null,
});
const saved = await this.repo.save(entity);
const withRelations = await this.findOne(saved.id);
await this.notifySubmitted(withRelations, applicant);
return withRelations;
}
findMine(applicantId: string): Promise<BusinessRegistrationRequestEntity[]> {
return this.repo.find({
where: { applicantId },
relations: { category: true, business: true },
order: { createdAt: 'DESC' },
});
}
findAll(filters: ListFilters): Promise<BusinessRegistrationRequestEntity[]> {
const qb = this.repo
.createQueryBuilder('req')
.leftJoinAndSelect('req.applicant', 'applicant')
.leftJoinAndSelect('req.category', 'category')
.leftJoinAndSelect('req.business', 'business')
.orderBy('req.createdAt', 'DESC');
if (filters.status) {
qb.andWhere('req.status = :status', { status: filters.status });
}
return qb.getMany();
}
async findOne(id: string): Promise<BusinessRegistrationRequestEntity> {
const entity = await this.repo.findOne({
where: { id },
relations: { applicant: true, category: true, business: true, reviewedBy: true },
});
if (!entity) {
throw new NotFoundException('Registration request not found');
}
return entity;
}
async approve(id: string, admin: AuthUser): Promise<BusinessRegistrationRequestEntity> {
const request = await this.findOne(id);
if (request.status !== BusinessRegistrationStatus.PENDING) {
throw new BadRequestException('Only pending requests can be approved');
}
const slugTaken = await this.businessRepo.findOne({ where: { slug: request.slug } });
if (slugTaken) {
throw new ConflictException('Business slug already exists');
}
const applicant = await this.userRepo.findOne({ where: { id: request.applicantId } });
if (!applicant) {
throw new NotFoundException('Applicant not found');
}
const business = this.businessRepo.create({
title: request.title,
slug: request.slug,
categoryId: request.categoryId,
description: request.description,
contactPhone: request.contactPhone,
address: request.address,
latitude: request.latitude,
longitude: request.longitude,
ownerId: applicant.id,
isActive: true,
});
const savedBusiness = await this.businessRepo.save(business);
if (applicant.role !== UserRole.ADMIN) {
applicant.role = UserRole.BUSINESS_OWNER;
}
applicant.isActive = true;
await this.userRepo.save(applicant);
request.status = BusinessRegistrationStatus.APPROVED;
request.businessId = savedBusiness.id;
request.reviewedById = admin.id;
request.reviewedAt = new Date();
request.adminNote = null;
const savedRequest = await this.repo.save(request);
const result = await this.findOne(savedRequest.id);
await this.notifyApproved(result, applicant);
return result;
}
async reject(
id: string,
admin: AuthUser,
dto: RejectBusinessRegistrationDto,
): Promise<BusinessRegistrationRequestEntity> {
const request = await this.findOne(id);
if (request.status !== BusinessRegistrationStatus.PENDING) {
throw new BadRequestException('Only pending requests can be rejected');
}
request.status = BusinessRegistrationStatus.REJECTED;
request.adminNote = dto.adminNote?.trim() ?? null;
request.reviewedById = admin.id;
request.reviewedAt = new Date();
const saved = await this.repo.save(request);
const result = await this.findOne(saved.id);
const applicant = result.applicant;
if (applicant) {
await this.notifyRejected(result, applicant);
}
return result;
}
private async ensureCategoryExists(categoryId: string): Promise<void> {
const category = await this.categoryRepo.findOne({ where: { id: categoryId } });
if (!category) {
throw new NotFoundException('Business category not found');
}
}
private async notifySubmitted(
request: BusinessRegistrationRequestEntity,
applicant: UserEntity,
): Promise<void> {
await this.sms.sendSms(
applicant.mobile,
`درخواست ثبت «${request.title}» در منتایم ثبت شد و در انتظار تأیید است.`,
);
const adminMobile = this.config.get<string>('SUPER_ADMIN_MOBILE');
if (adminMobile) {
await this.sms.sendSms(
adminMobile,
`درخواست کسب‌وکار جدید: ${request.title} — ${applicant.mobile}`,
);
}
}
private async notifyApproved(
request: BusinessRegistrationRequestEntity,
applicant: UserEntity,
): Promise<void> {
await this.sms.sendSms(
applicant.mobile,
`کسب‌وکار «${request.title}» در منتایم تأیید شد. پنل مدیریت در اپ فعال است.`,
);
}
private async notifyRejected(
request: BusinessRegistrationRequestEntity,
applicant: UserEntity,
): Promise<void> {
const note = request.adminNote ? ` ${request.adminNote}` : '';
await this.sms.sendSms(
applicant.mobile,
`درخواست «${request.title}» در منتایم رد شد.${note}`,
);
}
}
@@ -0,0 +1,48 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class CreateBusinessRegistrationDto {
@ApiProperty({ example: 'کارواش محمد' })
@IsString()
@MaxLength(150)
title!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
categoryId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ example: '09121234567' })
@IsOptional()
@IsString()
@MaxLength(20)
contactPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsLatitude()
latitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsLongitude()
longitude?: number;
}
@@ -0,0 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class RejectBusinessRegistrationDto {
@ApiPropertyOptional({ example: 'اطلاعات ناقص است' })
@IsOptional()
@IsString()
@MaxLength(500)
adminNote?: string;
}
@@ -48,6 +48,13 @@ export class BusinessesController {
});
}
@Get('mine')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
findMine(@CurrentUser() user: AuthUser): Promise<BusinessEntity[]> {
return this.service.findMine(user.id);
}
@Get(':id')
findOne(@Param('id') id: string): Promise<BusinessEntity> {
return this.service.findOne(id);
@@ -70,6 +70,14 @@ export class BusinessesService {
return business;
}
async findMine(ownerId: string): Promise<BusinessEntity[]> {
return this.repo.find({
where: { ownerId },
relations: { category: true, images: true },
order: { createdAt: 'DESC' },
});
}
async create(payload: CreateBusinessDto): Promise<BusinessEntity> {
const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } });
if (slugTaken) {