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:
@@ -11,6 +11,7 @@ import { BusinessServicesModule } from './modules/business-services/business-ser
|
|||||||
import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module';
|
import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module';
|
||||||
import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module';
|
import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module';
|
||||||
import { BookingsModule } from './modules/bookings/bookings.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 { AuthModule } from './modules/auth/auth.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
AttributeDefinitionsModule,
|
AttributeDefinitionsModule,
|
||||||
BusinessAttributeValuesModule,
|
BusinessAttributeValuesModule,
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
|
BusinessRegistrationRequestsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -35,7 +35,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('Montime API')
|
.setTitle('Montime API')
|
||||||
.setDescription('Local services super app API')
|
.setDescription('Montime (منتایم) — Local services super app API')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
.addBearerAuth(
|
.addBearerAuth(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ import { RolesGuard } from './guards/roles.guard';
|
|||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard],
|
providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard],
|
||||||
exports: [JwtAuthGuard, RolesGuard, JwtModule],
|
exports: [JwtAuthGuard, RolesGuard, JwtModule, SmsService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||||
const text = `کد ورود شما: ${code}`;
|
const text = `کد ورود منتایم: ${code}`;
|
||||||
return this.sendSms(mobile, text);
|
return this.sendSms(mobile, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
@@ -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')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string): Promise<BusinessEntity> {
|
findOne(@Param('id') id: string): Promise<BusinessEntity> {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ export class BusinessesService {
|
|||||||
return business;
|
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> {
|
async create(payload: CreateBusinessDto): Promise<BusinessEntity> {
|
||||||
const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } });
|
const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } });
|
||||||
if (slugTaken) {
|
if (slugTaken) {
|
||||||
|
|||||||
Reference in New Issue
Block a user