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
+2
View File
@@ -11,6 +11,7 @@ import { BusinessServicesModule } from './modules/business-services/business-ser
import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module';
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 { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
@@ -25,6 +26,7 @@ import { UsersModule } from './modules/users/users.module';
AttributeDefinitionsModule,
BusinessAttributeValuesModule,
BookingsModule,
BusinessRegistrationRequestsModule,
AuthModule,
UsersModule,
],
+26
View File
@@ -0,0 +1,26 @@
export function slugifyTitle(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export async function uniqueBusinessSlug(
title: string,
exists: (slug: string) => Promise<boolean>,
): Promise<string> {
let base = slugifyTitle(title);
if (!base) {
base = 'business';
}
let slug = base;
let suffix = 0;
while (await exists(slug)) {
suffix += 1;
slug = `${base}-${suffix}`;
}
return slug;
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateBusinessRegistrationRequests1789042000000
implements MigrationInterface
{
name = 'CreateBusinessRegistrationRequests1789042000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "business_registration_requests" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"applicantId" uuid NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'pending',
"title" character varying(150) NOT NULL,
"slug" character varying(120) NOT NULL,
"categoryId" uuid NOT NULL,
"description" character varying(500),
"contactPhone" character varying(20),
"address" character varying(500),
"latitude" double precision,
"longitude" double precision,
"adminNote" character varying(500),
"reviewedById" uuid,
"reviewedAt" TIMESTAMP WITH TIME ZONE,
"businessId" uuid,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_business_registration_requests" PRIMARY KEY ("id"),
CONSTRAINT "FK_brr_applicant" FOREIGN KEY ("applicantId")
REFERENCES "users"("id") ON DELETE CASCADE,
CONSTRAINT "FK_brr_category" FOREIGN KEY ("categoryId")
REFERENCES "business_categories"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_brr_reviewed_by" FOREIGN KEY ("reviewedById")
REFERENCES "users"("id") ON DELETE SET NULL,
CONSTRAINT "FK_brr_business" FOREIGN KEY ("businessId")
REFERENCES "businesses"("id") ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_brr_applicant_status"
ON "business_registration_requests" ("applicantId", "status")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "business_registration_requests"`);
}
}
@@ -0,0 +1,92 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { BusinessCategoryEntity } from './business-category.entity';
import { BusinessEntity } from './business.entity';
import { UserEntity } from './user.entity';
export enum BusinessRegistrationStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
}
@Entity('business_registration_requests')
export class BusinessRegistrationRequestEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicantId' })
applicant!: UserEntity;
@Column('uuid')
applicantId!: string;
@Column({
type: 'varchar',
length: 20,
default: BusinessRegistrationStatus.PENDING,
})
status!: BusinessRegistrationStatus;
@Column({ length: 150 })
title!: string;
@Column({ length: 120 })
slug!: string;
@ManyToOne(() => BusinessCategoryEntity, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'categoryId' })
category!: BusinessCategoryEntity;
@Column('uuid')
categoryId!: string;
@Column({ type: 'varchar', nullable: true, length: 500 })
description!: string | null;
@Column({ type: 'varchar', nullable: true, length: 20 })
contactPhone!: string | null;
@Column({ type: 'varchar', nullable: true, length: 500 })
address!: string | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: 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(() => BusinessEntity, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'businessId' })
business!: BusinessEntity | null;
@Column({ type: 'uuid', nullable: true })
businessId!: string | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
+1 -1
View File
@@ -35,7 +35,7 @@ async function bootstrap() {
const swaggerConfig = new DocumentBuilder()
.setTitle('Montime API')
.setDescription('Local services super app API')
.setDescription('Montime (منتایم) — Local services super app API')
.setVersion('1.0')
.addBearerAuth(
{
+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) {