diff --git a/src/app.module.ts b/src/app.module.ts index 19ef0ee..cc7ecf5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, ], diff --git a/src/common/utils/slug.util.ts b/src/common/utils/slug.util.ts new file mode 100644 index 0000000..305eaba --- /dev/null +++ b/src/common/utils/slug.util.ts @@ -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, +): Promise { + 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; +} diff --git a/src/database/migrations/1789042000000-CreateBusinessRegistrationRequests.ts b/src/database/migrations/1789042000000-CreateBusinessRegistrationRequests.ts new file mode 100644 index 0000000..3b6abce --- /dev/null +++ b/src/database/migrations/1789042000000-CreateBusinessRegistrationRequests.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateBusinessRegistrationRequests1789042000000 + implements MigrationInterface +{ + name = 'CreateBusinessRegistrationRequests1789042000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "business_registration_requests"`); + } +} diff --git a/src/entities/business-registration-request.entity.ts b/src/entities/business-registration-request.entity.ts new file mode 100644 index 0000000..b134dfe --- /dev/null +++ b/src/entities/business-registration-request.entity.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 45fc8a9..6911528 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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( { diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 8f1a28f..43640fc 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts index 528bf09..87b116e 100644 --- a/src/modules/auth/sms.service.ts +++ b/src/modules/auth/sms.service.ts @@ -37,7 +37,7 @@ export class SmsService { } async sendOtpSms(mobile: string, code: string): Promise { - const text = `کد ورود شما: ${code}`; + const text = `کد ورود منتایم: ${code}`; return this.sendSms(mobile, text); } diff --git a/src/modules/business-registration-requests/business-registration-requests.controller.ts b/src/modules/business-registration-requests/business-registration-requests.controller.ts new file mode 100644 index 0000000..b6e6121 --- /dev/null +++ b/src/modules/business-registration-requests/business-registration-requests.controller.ts @@ -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 { + return this.service.create(user.id, payload); + } + + @Get('mine') + @UseGuards(JwtAuthGuard) + findMine( + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.findMine(user.id); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + findAll( + @Query('status') status?: BusinessRegistrationStatus, + ): Promise { + return this.service.findAll({ status }); + } + + @Get(':id') + @UseGuards(JwtAuthGuard) + findOne( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + 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 { + 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 { + return this.service.reject(id, user, payload); + } +} diff --git a/src/modules/business-registration-requests/business-registration-requests.module.ts b/src/modules/business-registration-requests/business-registration-requests.module.ts new file mode 100644 index 0000000..58cb64a --- /dev/null +++ b/src/modules/business-registration-requests/business-registration-requests.module.ts @@ -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 {} diff --git a/src/modules/business-registration-requests/business-registration-requests.service.ts b/src/modules/business-registration-requests/business-registration-requests.service.ts new file mode 100644 index 0000000..cfa0ee5 --- /dev/null +++ b/src/modules/business-registration-requests/business-registration-requests.service.ts @@ -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, + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + @InjectRepository(BusinessCategoryEntity) + private readonly categoryRepo: Repository, + @InjectRepository(UserEntity) + private readonly userRepo: Repository, + private readonly sms: SmsService, + private readonly config: ConfigService, + ) {} + + async create( + applicantId: string, + dto: CreateBusinessRegistrationDto, + ): Promise { + 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 { + return this.repo.find({ + where: { applicantId }, + relations: { category: true, business: true }, + order: { createdAt: 'DESC' }, + }); + } + + findAll(filters: ListFilters): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.sms.sendSms( + applicant.mobile, + `درخواست ثبت «${request.title}» در منتایم ثبت شد و در انتظار تأیید است.`, + ); + + const adminMobile = this.config.get('SUPER_ADMIN_MOBILE'); + if (adminMobile) { + await this.sms.sendSms( + adminMobile, + `درخواست کسب‌وکار جدید: ${request.title} — ${applicant.mobile}`, + ); + } + } + + private async notifyApproved( + request: BusinessRegistrationRequestEntity, + applicant: UserEntity, + ): Promise { + await this.sms.sendSms( + applicant.mobile, + `کسب‌وکار «${request.title}» در منتایم تأیید شد. پنل مدیریت در اپ فعال است.`, + ); + } + + private async notifyRejected( + request: BusinessRegistrationRequestEntity, + applicant: UserEntity, + ): Promise { + const note = request.adminNote ? ` ${request.adminNote}` : ''; + await this.sms.sendSms( + applicant.mobile, + `درخواست «${request.title}» در منتایم رد شد.${note}`, + ); + } +} diff --git a/src/modules/business-registration-requests/dto/create-business-registration.dto.ts b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts new file mode 100644 index 0000000..03c24c1 --- /dev/null +++ b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts @@ -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; +} diff --git a/src/modules/business-registration-requests/dto/reject-business-registration.dto.ts b/src/modules/business-registration-requests/dto/reject-business-registration.dto.ts new file mode 100644 index 0000000..13aeac5 --- /dev/null +++ b/src/modules/business-registration-requests/dto/reject-business-registration.dto.ts @@ -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; +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index ea6edd5..626fd22 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -48,6 +48,13 @@ export class BusinessesController { }); } + @Get('mine') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + findMine(@CurrentUser() user: AuthUser): Promise { + return this.service.findMine(user.id); + } + @Get(':id') findOne(@Param('id') id: string): Promise { return this.service.findOne(id); diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 9c96704..d233d11 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -70,6 +70,14 @@ export class BusinessesService { return business; } + async findMine(ownerId: string): Promise { + return this.repo.find({ + where: { ownerId }, + relations: { category: true, images: true }, + order: { createdAt: 'DESC' }, + }); + } + async create(payload: CreateBusinessDto): Promise { const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } }); if (slugTaken) {