diff --git a/database/migrations/1775992337764-AddREseller.ts b/database/migrations/1775992337764-AddREseller.ts new file mode 100644 index 0000000..b838e24 --- /dev/null +++ b/database/migrations/1775992337764-AddREseller.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddREseller1775992337764 implements MigrationInterface { + name = 'AddREseller1775992337764' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "reseller" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(100) NOT NULL, "ownerId" uuid, CONSTRAINT "UQ_4993ba05f9437d2c32ea8adde42" UNIQUE ("name"), CONSTRAINT "REL_e2c8deee8c9000aefd91c17486" UNIQUE ("ownerId"), CONSTRAINT "PK_fbb203c7b490e89a011e62f2faf" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "user" ADD "resellerId" uuid`); + await queryRunner.query(`ALTER TABLE "reseller" ADD CONSTRAINT "FK_e2c8deee8c9000aefd91c174860" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_f39518997d27b06d20bd1b08af0" FOREIGN KEY ("resellerId") REFERENCES "reseller"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_f39518997d27b06d20bd1b08af0"`); + await queryRunner.query(`ALTER TABLE "reseller" DROP CONSTRAINT "FK_e2c8deee8c9000aefd91c174860"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "resellerId"`); + await queryRunner.query(`DROP TABLE "reseller"`); + } + +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 967f5ac..c021481 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -62,6 +62,7 @@ export const enum AuthMessage { CURRENT_PASSWORD_INVALID = "رمز عبور فعلی نامعتبر است", TOKEN_EXPIRED_OR_INVALID = "توکن منقضی شده یا نامعتبر است", ACCESS_DENIED = "دسترسی شما محدود شده است", + NOT_AGENT="شما مجوز دسترسی به بخش نمایندگان را ندارید" } export const enum UserMessage { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 26aafc6..8c6a7f8 100755 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -33,6 +33,14 @@ export class AuthController { return this.authService.requestLoginOtp(requestOtpDto); } + + @ApiOperation({ summary: "request to login with otp" }) + @HttpCode(HttpStatus.OK) + @Post("otp/send/reseller") + sendOtpReseller(@Body() requestOtpDto: RequestOtpDto) { + return this.authService.requestLoginOtpAgentAndReseller(requestOtpDto); + } + @ApiOperation({ summary: "verify otp for login" }) @HttpCode(HttpStatus.OK) @Post("otp/verify") @@ -69,6 +77,13 @@ export class AuthController { return this.authService.adminVerifyLoginOtp(verifyOtpDto); } + @ApiOperation({ summary: "verify otp for login" }) + @HttpCode(HttpStatus.OK) + @Post("otp/verify/agent") + resellerVerifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { + return this.authService.requestLoginOtpAgentAndReseller(verifyOtpDto); + } + @AuthGuards() @ApiOperation({ summary: "Update user password" }) @HttpCode(HttpStatus.OK) diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts index 32bb8cd..702e5ee 100755 --- a/src/modules/auth/providers/auth.service.ts +++ b/src/modules/auth/providers/auth.service.ts @@ -20,6 +20,7 @@ import { VerifyOtpDto } from "../DTO/verify-otp.dto"; import { ForgetPasswordDto } from "../DTO/forget-password.dto"; import { VerifyForgotPasswordOtpDto } from "../DTO/verify-forgot-otp.dto"; import { EmailService } from "../../utils/providers/email.service"; +import { User } from "../../users/entities/user.entity"; @Injectable() export class AuthService { @@ -111,6 +112,35 @@ export class AuthService { //****************** */ //****************** */ + async requestLoginOtpAgentAndReseller(requestOtpDto: RequestOtpDto) { + const { phone } = requestOtpDto; + + // check if agent with this phone exist or not + const { reseller, agent } = await this.checkAgentExistWithPhone(phone) + + const existCode = await this.otpService.checkExistOtp(phone, "LOGIN_RESELLER_AGENT"); + if (existCode) { + return { + message: AuthMessage.OTP_ALREADY_SENT, + ttlSecond: existCode, + }; + } + + const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN_RESELLER_AGENT"); + // + this.logger.log(`otp code: ${otpCode} for phone: ${phone}`); + await this.notificationQueue.addLoginOtpNotification({ phone, code: otpCode }); + + return { + message: AuthMessage.OTP_SENT + otpCode, + reseller, + agent + }; + } + + //****************** */ + //****************** */ + async verifyLoginOtp(verifyOtpDto: VerifyOtpDto) { const { code, phone, referralCode } = verifyOtpDto; @@ -198,6 +228,31 @@ export class AuthService { } //****************** */ + async resellerVerifyLoginOtp(verifyOtpDto: VerifyOtpDto) { + const { code, phone } = verifyOtpDto; + + await this.checkResellerOtpWithPhone(phone, code) + + const { agent, reseller } = await this.checkAgentExistWithPhone(phone); + + + const user: User | null = agent ?? reseller + + if (!user) { + throw new BadRequestException(AuthMessage.USER_NOT_FOUND) + } + + if (user.deletedAt) throw new BadRequestException(AuthMessage.ACCESS_DENIED); + + const tokens = await this.tokensService.generateTokens(user); + + return { + message: AuthMessage.LOGIN_SUCCESS, + ...tokens, + }; + } + //****************** */ + async changePassword(userId: string, updateDto: ChangePasswordDto) { const { user } = await this.usersService.findOneById(userId); @@ -249,6 +304,27 @@ export class AuthService { return user; } + + private async checkResellerOtpWithPhone(phone: string, otpCode: string) { + const isValid = await this.otpService.verifyOtp(phone, otpCode, "LOGIN_RESELLER_AGENT"); + if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP); + + return true; + } + //****************** */ + //****************** */ + private async checkAgentExistWithPhone(phone: string) { + + const { reseller } = await this.usersService.getResellerByUserPhone(phone); + + const { agent } = await this.usersService.getAgentByUserPhone(phone); + + if (!reseller && !agent) { + throw new BadRequestException(AuthMessage.USER_NOT_FOUND) + } + + return { reseller, agent } + } //****************** */ //****************** */ async requestForgotPasswordOtp(dto: ForgetPasswordDto) { @@ -270,7 +346,7 @@ export class AuthService { } const otpCode = await this.otpService.generateAndSetInCache(email, "FORGOT_PASSWORD"); - + await this.emailService.sendOtpEmail({ userEmail: email, otp: otpCode }); this.logger.log(`otp code: ${otpCode} for email: ${email}`); @@ -306,4 +382,9 @@ export class AuthService { private checkUserIsAdmin(roles: Role[]) { return roles.some((role) => role.isAdmin); } + //****************** */ + //****************** */ + // private checkUserIsAgent(roles: Role[]) { + // return roles.some((role) => role.isAgent); + // } } diff --git a/src/modules/users/DTO/create-reseller.dto.ts b/src/modules/users/DTO/create-reseller.dto.ts new file mode 100755 index 0000000..7d3235b --- /dev/null +++ b/src/modules/users/DTO/create-reseller.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsMobilePhone, IsNotEmpty, IsString, Length } from "class-validator"; +import { AuthMessage } from "../../../common/enums/message.enum"; + + +export class CreateResellerDto { + @IsNotEmpty() + @IsString() + @Length(2, 50,) + @ApiProperty({ description: "name", example: "mahyar" }) + name: string; + + @IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY }) + @IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT }) + @Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT }) + @ApiProperty({ description: "phone number", default: "09922320740" }) + phone: string; + +} diff --git a/src/modules/users/entities/reseller.entity.ts b/src/modules/users/entities/reseller.entity.ts new file mode 100755 index 0000000..3ea800b --- /dev/null +++ b/src/modules/users/entities/reseller.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm"; + +import { User } from "./user.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; + +@Entity() +export class Reseller extends BaseEntity { + @Column({ type: "varchar", length: 100, unique: true }) + name: string; + + //--------------------------------------- + + @OneToOne(() => User,user=>user.ownedReseller) + @JoinColumn() + owner!: User; + + @OneToMany(() => User, user => user.reseller) + agents: User[]; +} diff --git a/src/modules/users/entities/role.entity.ts b/src/modules/users/entities/role.entity.ts index 7a99357..a799f35 100755 --- a/src/modules/users/entities/role.entity.ts +++ b/src/modules/users/entities/role.entity.ts @@ -15,6 +15,9 @@ export class Role extends BaseEntity { @Column({ type: "boolean", default: false }) isAdmin: boolean; + // @Column({ type: "boolean", default: false }) + // isAgent: boolean; + @ManyToMany(() => Permission, (permission) => permission.roles) @JoinTable({ name: "role_permission_relation" }) permissions: Permission[]; diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index e8612e3..e931013 100755 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,5 +1,5 @@ import { Exclude } from "class-transformer"; -import { Column, DeleteDateColumn, Entity, JoinTable, ManyToMany, OneToMany, OneToOne } from "typeorm"; +import { Column, DeleteDateColumn, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne } from "typeorm"; import { LegalUser } from "./legal-user.entity"; import { RealUser } from "./real-user.entity"; @@ -30,6 +30,7 @@ import { TicketMessage } from "../../tickets/entities/ticket-message.entity"; import { Ticket } from "../../tickets/entities/ticket.entity"; import { Wallet } from "../../wallets/entities/wallet.entity"; import { FinancialType } from "../enums/financial-type.enum"; +import { Reseller } from "./reseller.entity"; @Entity() export class User extends BaseEntity { @@ -161,4 +162,13 @@ export class User extends BaseEntity { @OneToMany(() => ReferralReward, (reward) => reward.user) referralRewards: ReferralReward[]; + + //--------------------------------------- + + @ManyToOne(() => Reseller, (reseller) => reseller.agents, { nullable: true }) + reseller?: Reseller + + + @OneToOne(() => Reseller, reseller => reseller.owner, { nullable: true }) + ownedReseller?: Reseller } diff --git a/src/modules/users/enums/permission.enum.ts b/src/modules/users/enums/permission.enum.ts index 1b808dc..5a42f37 100755 --- a/src/modules/users/enums/permission.enum.ts +++ b/src/modules/users/enums/permission.enum.ts @@ -23,5 +23,6 @@ export enum PermissionEnum { DMENU = "dmenu", DKALA = "dkala", DPAGE = "dpage", - DMAIL = 'dmail' + DMAIL = 'dmail', + RESELLER="reseller" } diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 6dec136..efcbb6e 100755 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -30,6 +30,8 @@ import { LegalUserRepository } from "../repositories/legal-user.repository"; import { RealUserRepository } from "../repositories/real-user.repository"; import { UserGroupRepository } from "../repositories/user-group.repository"; import { UserRepository } from "../repositories/users.repository"; +import { ResellerRepository } from "../repositories/reseller.repository"; +import { CreateResellerDto } from "../DTO/create-reseller.dto"; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); @@ -47,7 +49,8 @@ export class UsersService { private readonly smsService: SmsService, private readonly cacheService: CacheService, private readonly referralsService: ReferralsService, - ) {} + private readonly resellerRepository: ResellerRepository, + ) { } /************************************************************ */ @@ -370,6 +373,29 @@ export class UsersService { /************************************************************ */ + async getResellerByUserPhone(phone: string) { + const reseller = await this.userRepository.findOne({ where: { ownedReseller: { owner: { phone } } } }); + return { reseller }; + } + + /************************************************************ */ + + async getAgentByUserPhone(phone: string) { + const agent = await this.userRepository.findOne({ where: { reseller: { agents: { phone } } } }); + return { agent }; + } + + /************************************************************ */ + + async getResellersList() { + this.resellerRepository.find({ + relations: { + owner: true + } + }) + } + /************************************************************ */ + private async createEmailHashLink(email: string) { const secret = this.configService.getOrThrow("EMAIL_SECRET"); const nonce = randomBytes(16).toString("hex"); @@ -399,4 +425,23 @@ export class UsersService { } /************************************************************ */ + + async createReseller(dto: CreateResellerDto) { + const { name, phone } = dto + + const user = await this.findOneWithPhone(phone) + + if (!user) { + throw new BadRequestException(AuthMessage.USER_NOT_FOUND) + } + + const reseller = this.resellerRepository.create({ + name, + owner: user + }) + + await this.resellerRepository.save(reseller) + + return reseller + } } diff --git a/src/modules/users/repositories/reseller.repository.ts b/src/modules/users/repositories/reseller.repository.ts new file mode 100755 index 0000000..7540a34 --- /dev/null +++ b/src/modules/users/repositories/reseller.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { Reseller } from "../entities/reseller.entity"; + + +@Injectable() +export class ResellerRepository extends Repository { + constructor(@InjectRepository(Reseller) resellerRepository: Repository) { + super(resellerRepository.target, resellerRepository.manager, resellerRepository.queryRunner); + } +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 1c8664e..465e2c4 100755 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -29,6 +29,7 @@ import { IUserIpAndHeaders, UserDec, UserIpAndHeaders } from "../../common/decor import { ParamDto } from "../../common/DTO/param.dto"; import { RequestOtpDto } from "../auth/DTO/request-otp.dto"; import { VerifyOtpDto } from "../auth/DTO/verify-otp.dto"; +import { CreateResellerDto } from "./DTO/create-reseller.dto"; @AuthGuards() @Controller("users") @@ -37,7 +38,7 @@ export class UsersController { private readonly usersService: UsersService, private readonly adminsService: AdminsService, private readonly customersService: CustomersService, - ) {} + ) { } //----------------------- user ----------------------- @@ -228,4 +229,20 @@ export class UsersController { createRole(@Body() createDto: CreateRoleDto) { return this.adminsService.createRole(createDto); } + + // ==================== Reseller ========= + + @ApiOperation({ summary: "create Reseller ==> admin route" }) + @PermissionsDec(PermissionEnum.RESELLER) + @Post("reseller") + createReseller(@Body() dto: CreateResellerDto) { + return this.usersService.createReseller(dto) + } + + @ApiOperation({ summary: "get Resellers ==> admin route" }) + @PermissionsDec(PermissionEnum.RESELLER) + @Get("reseller") + getResellers( ) { + return this.usersService.getResellersList() + } } diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 0c464de..f80a3d3 100755 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -28,10 +28,12 @@ import { AdminsService } from "./providers/admins.service"; import { CustomersService } from "./providers/customers.service"; import { RefreshTokensRepository } from "./repositories/refresh-token.repository"; import { ReferralsModule } from "../referrals/referrals.module"; +import { ResellerRepository } from "./repositories/reseller.repository"; +import { Reseller } from "./entities/reseller.entity"; @Module({ imports: [ - TypeOrmModule.forFeature([User, Role, UserGroup, UserSetting, RealUser, LegalUser, Permission, RefreshToken]), + TypeOrmModule.forFeature([User, Role, UserGroup, UserSetting, RealUser, LegalUser, Permission, RefreshToken,Reseller]), WalletsModule, UtilsModule, AddressModule, @@ -52,6 +54,7 @@ import { ReferralsModule } from "../referrals/referrals.module"; RealUserRepository, LegalUserRepository, AddressService, + ResellerRepository ], controllers: [UsersController], exports: [UsersService, UserRepository, CustomersService, AdminsService, RefreshTokensRepository], diff --git a/src/modules/utils/interfaces/IOtpKey.ts b/src/modules/utils/interfaces/IOtpKey.ts index 10fe7a0..094cf1f 100755 --- a/src/modules/utils/interfaces/IOtpKey.ts +++ b/src/modules/utils/interfaces/IOtpKey.ts @@ -1 +1 @@ -export type OtpCacheKeyType = "LOGIN/REGISTER" | "FORGOT_PASSWORD" | "INVOICE_VERIFY"; +export type OtpCacheKeyType = "LOGIN/REGISTER" | "FORGOT_PASSWORD" | "INVOICE_VERIFY"|"LOGIN_RESELLER_AGENT";