chore: change the login and regitster flow to single route

This commit is contained in:
mahyargdz
2025-08-16 12:50:58 +03:30
parent 629b8dbdd5
commit ef57dc7107
24 changed files with 340 additions and 235 deletions
+2
View File
@@ -391,6 +391,8 @@ export const enum NotificationMessage {
USERID_IS_STRING = "شناسه کاربر باید یک رشته متنی باشد",
LOGIN = "لاگین",
LOGIN_MESSAGE = "یک لاگین در تاریخ [loginDate] به حساب کاربری شما صورت گرفت",
USER_PASSWORD = "رمز عبور پیش‌فرض",
USER_PASSWORD_MESSAGE = "رمز عبور پیش‌فرض شما: [password]",
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی غیرمجاز است",
INVOICE_CREATION = "صدور صورت حساب",
INVOICE_CREATION_MESSAGE = "یک صورت حساب به شماره [id] جدید برای شما صادر شده است",
+2
View File
@@ -32,6 +32,7 @@ export function smsConfigs() {
SMS_PATTERN_NEW_SUBSCRIPTION: configService.getOrThrow<string>("SMS_PATTERN_NEW_SUBSCRIPTION"),
SMS_PATTERN_NEW_TICKET_GLOBAL: configService.getOrThrow<string>("SMS_PATTERN_NEW_TICKET_GLOBAL"),
SMS_PATTERN_NEW_CRITICISM: configService.getOrThrow<string>("SMS_PATTERN_NEW_CRITICISM"),
SMS_PATTERN_USER_PASSWORD: configService.getOrThrow<string>("SMS_PATTERN_USER_PASSWORD"),
};
},
};
@@ -65,4 +66,5 @@ export interface ISmsConfigs {
SMS_PATTERN_NEW_SUBSCRIPTION: string;
SMS_PATTERN_NEW_TICKET_GLOBAL: string;
SMS_PATTERN_NEW_CRITICISM: string;
SMS_PATTERN_USER_PASSWORD: string;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions {
username: configService.getOrThrow<string>("DB_USER"),
password: configService.getOrThrow<string>("DB_PASS"),
autoLoadEntities: true,
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : false,
logging: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
migrationsTableName: "typeorm_migrations",
migrationsRun: false,
+47 -47
View File
@@ -1,56 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsMobilePhone, IsNotEmpty, IsNumberString, IsOptional, IsString, Length, MinLength } from "class-validator";
// import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
// import { IsMobilePhone, IsNotEmpty, IsNumberString, IsOptional, IsString, Length } from "class-validator";
// import { IsNationalCode } from "../../../common/decorators/is-national-code.decorator";
import { AuthMessage, ReferralMessage } from "../../../common/enums/message.enum";
// // import { IsNationalCode } from "../../../common/decorators/is-national-code.decorator";
// import { AuthMessage, ReferralMessage } from "../../../common/enums/message.enum";
export class CompleteRegistrationDto {
@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;
// export class CompleteRegistrationDto {
// @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;
@ApiProperty({ description: "OTP code received via SMS", example: "56893" })
@IsNotEmpty({ message: AuthMessage.OTP_NOT_EMPTY })
@IsNumberString(undefined, { message: AuthMessage.OTP_FORMAT_INVALID })
@Length(5, 5, { message: AuthMessage.OTP_FORMAT_INVALID })
code: string;
// @ApiProperty({ description: "OTP code received via SMS", example: "56893" })
// @IsNotEmpty({ message: AuthMessage.OTP_NOT_EMPTY })
// @IsNumberString(undefined, { message: AuthMessage.OTP_FORMAT_INVALID })
// @Length(5, 5, { message: AuthMessage.OTP_FORMAT_INVALID })
// code: string;
@IsNotEmpty({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "First name", example: "mahyar" })
firstName: string;
// // @IsNotEmpty({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
// // @IsString({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
// // @Length(2, 50, { message: AuthMessage.FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
// // @ApiProperty({ description: "First name", example: "mahyar" })
// // firstName: string;
@IsNotEmpty({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "Last name", example: "jamshidi" })
lastName: string;
// // @IsNotEmpty({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
// // @IsString({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
// // @Length(2, 50, { message: AuthMessage.LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
// // @ApiProperty({ description: "Last name", example: "jamshidi" })
// // lastName: string;
// @IsOptional()
// @IsNotEmpty({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
// @IsString({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
// @ApiProperty({ description: "Birth date", example: "1403/01/01" })
// birthDate: string;
// // @IsOptional()
// // @IsNotEmpty({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
// // @IsString({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
// // @ApiProperty({ description: "Birth date", example: "1403/01/01" })
// // birthDate: string;
// @IsOptional()
// @IsNotEmpty({ message: AuthMessage.NATIONAL_NOT_EMPTY })
// @IsNumberString({ no_symbols: true }, { message: AuthMessage.NATIONAL_CODE_INCORRECT })
// @Length(10, 10, { message: AuthMessage.NATIONAL_CODE_INCORRECT })
// @IsNationalCode({ message: AuthMessage.NATIONAL_CODE_INVALID })
// @ApiProperty({ description: "iranian format (10 char)", example: "4569852169" })
// nationalCode: string;
// // @IsOptional()
// // @IsNotEmpty({ message: AuthMessage.NATIONAL_NOT_EMPTY })
// // @IsNumberString({ no_symbols: true }, { message: AuthMessage.NATIONAL_CODE_INCORRECT })
// // @Length(10, 10, { message: AuthMessage.NATIONAL_CODE_INCORRECT })
// // @IsNationalCode({ message: AuthMessage.NATIONAL_CODE_INVALID })
// // @ApiProperty({ description: "iranian format (10 char)", example: "4569852169" })
// // nationalCode: string;
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.PASSWORD_FORMAT_INVALID })
@ApiProperty({ description: "password", example: "12S345SS678" })
@MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
password: string;
// // @IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
// // @IsString({ message: AuthMessage.PASSWORD_FORMAT_INVALID })
// // @ApiProperty({ description: "password", example: "12S345SS678" })
// // @MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
// // password: string;
@IsOptional()
@IsString({ message: ReferralMessage.INVALID_REFERRAL_CODE })
@ApiPropertyOptional({ description: "Referral code", example: "abc123" })
referralCode?: string;
}
// @IsOptional()
// @IsString({ message: ReferralMessage.INVALID_REFERRAL_CODE })
// @ApiPropertyOptional({ description: "Referral code", example: "abc123" })
// referralCode?: string;
// }
+8 -3
View File
@@ -1,7 +1,7 @@
import { ApiProperty, PickType } from "@nestjs/swagger";
import { IsMobilePhone, IsNotEmpty, IsNumberString, Length } from "class-validator";
import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger";
import { IsMobilePhone, IsNotEmpty, IsNumberString, IsOptional, IsString, Length } from "class-validator";
import { AuthMessage } from "../../../common/enums/message.enum";
import { AuthMessage, ReferralMessage } from "../../../common/enums/message.enum";
export class VerifyOtpDto {
@IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY })
@@ -15,6 +15,11 @@ export class VerifyOtpDto {
@IsNumberString(undefined, { message: AuthMessage.OTP_FORMAT_INVALID })
@Length(5, 5, { message: AuthMessage.OTP_FORMAT_INVALID })
code: string;
@IsOptional()
@IsString({ message: ReferralMessage.INVALID_REFERRAL_CODE })
@ApiPropertyOptional({ description: "Referral code", example: "abc123" })
referralCode?: string;
}
export class VerifyOtpWithUserId extends PickType(VerifyOtpDto, ["code"]) {}
-14
View File
@@ -3,7 +3,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
import { ChangePasswordDto } from "./DTO/change-password.dto";
import { CompleteRegistrationDto } from "./DTO/complete-register.dto";
import { CheckUserExistDto, LoginPasswordDTO } from "./DTO/loginPassword.dto";
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
import { RequestOtpDto } from "./DTO/request-otp.dto";
@@ -27,19 +26,6 @@ export class AuthController {
private readonly ssoService: SSOService,
) {}
@ApiOperation({ summary: "Initiate registration" })
@HttpCode(HttpStatus.OK)
@Post("register/initiate")
register(@Body() registerDto: RequestOtpDto) {
return this.authService.initiateRegistration(registerDto);
}
@ApiOperation({ summary: "complete registration ==> step 2" })
@Post("register/complete")
completeRegistration(@Body() completeRegistrationDto: CompleteRegistrationDto) {
return this.authService.completeRegistration(completeRegistrationDto);
}
@ApiOperation({ summary: "request to login with otp" })
@HttpCode(HttpStatus.OK)
@Post("otp/send")
+73 -105
View File
@@ -1,4 +1,6 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { randomBytes } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { TokensService } from "./tokens.service";
@@ -10,13 +12,14 @@ import { AdminsService } from "../../users/providers/admins.service";
import { UsersService } from "../../users/providers/users.service";
import { OTPService } from "../../utils/providers/otp.service";
import { PasswordService } from "../../utils/providers/password.service";
import { randomizeCase } from "../../utils/providers/string.utils";
import { ChangePasswordDto } from "../DTO/change-password.dto";
import { CompleteRegistrationDto } from "../DTO/complete-register.dto";
import { CheckUserExistDto, LoginPasswordDTO } from "../DTO/loginPassword.dto";
import { RequestOtpDto } from "../DTO/request-otp.dto";
import { VerifyOtpDto } from "../DTO/verify-otp.dto";
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly adminsService: AdminsService,
@@ -27,78 +30,7 @@ export class AuthService {
private readonly notificationQueue: NotificationQueue,
private readonly referralsService: ReferralsService,
) {}
//****************** */
//****************** */
async initiateRegistration(requestOtpDto: RequestOtpDto) {
const { phone } = requestOtpDto;
const existUser = await this.usersService.findOneWithPhone(phone);
if (existUser) throw new BadRequestException(AuthMessage.PHONE_EXISTS);
const existCode = await this.otpService.checkExistOtp(phone, "REGISTER");
if (existCode) {
return {
message: AuthMessage.OTP_ALREADY_SENT,
ttlSecond: existCode,
};
}
const otpCode = await this.otpService.generateAndSetInCache(phone, "REGISTER");
//
// await this.smsService.sendSmsVerifyCode(phone, otpCode);
await this.notificationQueue.addLoginOtpNotification({ phone, code: otpCode });
return {
message: AuthMessage.OTP_SENT,
};
}
//****************** */
//****************** */
async completeRegistration(completeRegistrationDto: CompleteRegistrationDto) {
const { phone, code, referralCode } = completeRegistrationDto;
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
//
const isValid = await this.otpService.verifyOtp(phone, code, "REGISTER");
if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP);
const existUser = await this.usersService.findOneWithPhone(phone);
if (existUser) throw new BadRequestException(AuthMessage.PHONE_EXISTS);
const hashedPassword = await this.passwordService.hashPassword(completeRegistrationDto.password);
const user = await this.usersService.createUser(completeRegistrationDto, hashedPassword, queryRunner);
if (referralCode) await this.referralsService.useReferralCode({ referralCode, userId: user.id }, queryRunner);
const tokens = await this.tokensService.generateTokens(user, queryRunner);
const superAdmins = await this.adminsService.getSuperAdmins(queryRunner);
for (const admin of superAdmins) {
await this.notificationQueue.addNewCustomerNotification(admin.id, {
userPhone: admin.phone,
userEmail: admin.email,
fullName: `${user.firstName} ${user.lastName}`,
mobile: user.phone,
});
}
await this.otpService.delOtpFormCache(phone, "REGISTER");
await queryRunner.commitTransaction();
return {
message: AuthMessage.USER_REGISTER_SUCCESS,
...tokens,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//****************** */
//****************** */
async loginWithPassword(loginDto: LoginPasswordDTO) {
@@ -150,18 +82,10 @@ export class AuthService {
//****************** */
//****************** */
async requestLoginOtp(requestOtpDto: RequestOtpDto, isAdmin: boolean = false) {
async requestLoginOtp(requestOtpDto: RequestOtpDto) {
const { phone } = requestOtpDto;
const user = await this.usersService.findOneWithPhone(phone);
if (!user) throw new BadRequestException(AuthMessage.PHONE_NOT_FOUND);
//check the if the method call is from admin or not
const isUserAdmin = this.checkUserIsAdmin(user.roles);
if (isAdmin && !isUserAdmin) throw new BadRequestException(AuthMessage.NOT_ADMIN);
const existCode = await this.otpService.checkExistOtp(phone, "LOGIN");
const existCode = await this.otpService.checkExistOtp(phone, "LOGIN/REGISTER");
if (existCode) {
return {
message: AuthMessage.OTP_ALREADY_SENT,
@@ -169,9 +93,9 @@ export class AuthService {
};
}
const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN");
const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN/REGISTER");
//
// await this.smsService.sendSmsVerifyCode(phone, otpCode);
this.logger.log(`otp code: ${otpCode} for phone: ${phone}`);
await this.notificationQueue.addLoginOtpNotification({ phone, code: otpCode });
return {
@@ -183,20 +107,67 @@ export class AuthService {
//****************** */
async verifyLoginOtp(verifyOtpDto: VerifyOtpDto) {
const { code, phone } = verifyOtpDto;
const { code, phone, referralCode } = verifyOtpDto;
const user = await this.checkUserLoginCredentialWithPhone(phone, code);
const queryRunner = this.dataSource.createQueryRunner();
if (!user.roles.some((role) => !role.isAdmin)) throw new BadRequestException(AuthMessage.ADMIN_CAN_NOT_LOGIN);
// if (user.roles.some((role) => role.isAdmin)) throw new BadRequestException(AuthMessage.ADMIN_CAN_NOT_LOGIN);
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const tokens = await this.tokensService.generateTokens(user);
const isValid = await this.otpService.verifyOtp(phone, code, "LOGIN/REGISTER");
if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP);
await this.notificationQueue.addLoginNotification(user.id, { userPhone: user.phone, userEmail: user.email });
return {
message: AuthMessage.LOGIN_SUCCESS,
...tokens,
};
let user = await this.usersService.findOneWithPhone(phone);
let isNewUser = false;
if (!user) {
const defaultUserPassword = randomizeCase(randomBytes(6).toString("hex"));
isNewUser = true;
const hashedPassword = await this.passwordService.hashPassword(defaultUserPassword);
user = await this.usersService.createUser(verifyOtpDto, hashedPassword, queryRunner);
if (referralCode) await this.referralsService.useReferralCode({ referralCode, userId: user.id }, queryRunner);
const superAdmins = await this.adminsService.getSuperAdmins(queryRunner);
for (const admin of superAdmins) {
await this.notificationQueue.addNewCustomerNotification(admin.id, {
userPhone: admin.phone,
userEmail: admin.email,
fullName: `${user.firstName} ${user.lastName}`,
mobile: user.phone,
});
}
await this.notificationQueue.addUserPasswordNotification(user.id, {
userPhone: user.phone,
userEmail: user.email,
password: defaultUserPassword,
});
}
if (!user.roles.some((role) => !role.isAdmin)) throw new BadRequestException(AuthMessage.ADMIN_CAN_NOT_LOGIN);
await this.notificationQueue.addLoginNotification(user.id, { userPhone: user.phone, userEmail: user.email });
const tokens = await this.tokensService.generateTokens(user, queryRunner);
await this.otpService.delOtpFormCache(phone, "LOGIN/REGISTER");
await queryRunner.commitTransaction();
return {
message: isNewUser ? AuthMessage.USER_REGISTER_SUCCESS : AuthMessage.LOGIN_SUCCESS,
...tokens,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//****************** */
@@ -204,7 +175,9 @@ export class AuthService {
async adminVerifyLoginOtp(verifyOtpDto: VerifyOtpDto) {
const { code, phone } = verifyOtpDto;
const user = await this.checkUserLoginCredentialWithPhone(phone, code);
const user = await this.checkUserAuthCredentialWithPhone(phone, code);
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const isUserAdmin = this.checkUserIsAdmin(user.roles);
@@ -260,23 +233,18 @@ export class AuthService {
return user;
}
//****************** */
//****************** */
private async checkUserLoginCredentialWithPhone(phone: string, otpCode: string) {
const isValid = await this.otpService.verifyOtp(phone, otpCode, "LOGIN");
private async checkUserAuthCredentialWithPhone(phone: string, otpCode: string) {
const isValid = await this.otpService.verifyOtp(phone, otpCode, "LOGIN/REGISTER");
if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP);
await this.otpService.delOtpFormCache(phone, "LOGIN");
await this.otpService.delOtpFormCache(phone, "LOGIN/REGISTER");
const user = await this.usersService.findOneWithPhone(phone);
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return user;
}
// private checkUserPerm(user: User, perm: PermissionEnum) {
// return user.roles.some((role) => role?.permissions?.some((p) => p.name === perm));
// }
//****************** */
//****************** */
private checkUserIsAdmin(roles: Role[]) {
@@ -7,6 +7,7 @@ export { NotificationHandlerFactory } from "./notification-handler.factory";
// Special handlers (non-transactional)
export { OtpHandler } from "./otp.handler";
export { UserLoginHandler } from "./user-login.handler";
export { UserPasswordHandler } from "./user-password.handler";
// Wallet handlers
export { WalletChargeHandler, WalletDeductionHandler } from "./wallet.handler";
@@ -16,6 +16,7 @@ import {
import { PaymentCancellationHandler, PaymentReminderHandler } from "./payment.handler";
import { BlockServiceHandler } from "./service.handler";
import { AnswerTicketHandler, AssignTicketHandler, CreateTicketHandler, NewTicketHandler } from "./ticket.handler";
import { UserPasswordHandler } from "./user-password.handler";
import { WalletChargeHandler, WalletDeductionHandler } from "./wallet.handler";
import { NotifType } from "../../settings/enums/notif-settings.enum";
@@ -25,11 +26,9 @@ export class NotificationHandlerFactory {
private readonly handlers: Map<NotifType, BaseNotificationHandler> = new Map();
constructor(
// Wallet handlers
private readonly walletChargeHandler: WalletChargeHandler,
private readonly walletDeductionHandler: WalletDeductionHandler,
// Invoice handlers
private readonly createInvoiceHandler: CreateInvoiceHandler,
private readonly billInvoiceReminderHandler: BillInvoiceReminderHandler,
private readonly billInvoiceHandler: BillInvoiceHandler,
@@ -37,7 +36,6 @@ export class NotificationHandlerFactory {
private readonly invoiceOverdueHandler: InvoiceOverdueHandler,
private readonly recurringInvoiceHandler: RecurringInvoiceHandler,
// Admin handlers
private readonly blogCommentHandler: BlogCommentHandler,
private readonly serviceReviewHandler: ServiceReviewHandler,
private readonly newCustomerHandler: NewCustomerHandler,
@@ -45,30 +43,26 @@ export class NotificationHandlerFactory {
private readonly newCriticismHandler: NewCriticismHandler,
private readonly newTicketHandler: NewTicketHandler,
// User handlers
private readonly announcementHandler: AnnouncementHandler,
// Ticket handlers
private readonly answerTicketHandler: AnswerTicketHandler,
private readonly createTicketHandler: CreateTicketHandler,
private readonly assignTicketHandler: AssignTicketHandler,
// Service handlers
private readonly blockServiceHandler: BlockServiceHandler,
// Payment handlers
private readonly paymentReminderHandler: PaymentReminderHandler,
private readonly paymentCancellationHandler: PaymentCancellationHandler,
private readonly userPasswordHandler: UserPasswordHandler,
) {
this.registerHandlers();
}
private registerHandlers(): void {
// Wallet notifications
this.handlers.set(NotifType.WALLET_CHARGE, this.walletChargeHandler);
this.handlers.set(NotifType.WALLET_DEDUCTION, this.walletDeductionHandler);
// Invoice notifications
this.handlers.set(NotifType.CREATE_INVOICE, this.createInvoiceHandler);
this.handlers.set(NotifType.BILL_INVOICE_REMINDER, this.billInvoiceReminderHandler);
this.handlers.set(NotifType.BILL_INVOICE, this.billInvoiceHandler);
@@ -76,7 +70,6 @@ export class NotificationHandlerFactory {
this.handlers.set(NotifType.INVOICE_OVERDUE, this.invoiceOverdueHandler);
this.handlers.set(NotifType.RECURRING_INVOICE, this.recurringInvoiceHandler);
// Admin notifications
this.handlers.set(NotifType.NEW_BLOG_COMMENT, this.blogCommentHandler);
this.handlers.set(NotifType.NEW_SERVICE_REVIEW, this.serviceReviewHandler);
this.handlers.set(NotifType.NEW_CUSTOMER, this.newCustomerHandler);
@@ -84,21 +77,19 @@ export class NotificationHandlerFactory {
this.handlers.set(NotifType.NEW_CRITICISM, this.newCriticismHandler);
this.handlers.set(NotifType.NEW_TICKET, this.newTicketHandler);
// User notifications
this.handlers.set(NotifType.ANNOUNCEMENT, this.announcementHandler);
// Ticket notifications
this.handlers.set(NotifType.ANSWER_TICKET, this.answerTicketHandler);
this.handlers.set(NotifType.CREATE_TICKET, this.createTicketHandler);
this.handlers.set(NotifType.ASSIGN_TICKET, this.assignTicketHandler);
// Service notifications
this.handlers.set(NotifType.BLOCK_SERVICE, this.blockServiceHandler);
// Payment notifications
this.handlers.set(NotifType.PAYMENT_REMINDER, this.paymentReminderHandler);
this.handlers.set(NotifType.PAYMENT_CANCELLATION, this.paymentCancellationHandler);
this.handlers.set(NotifType.USER_PASSWORD, this.userPasswordHandler);
this.logger.log(`Registered ${this.handlers.size} notification handlers`);
}
@@ -0,0 +1,23 @@
import { Injectable, Logger } from "@nestjs/common";
import { QueryRunner } from "typeorm";
import { BaseNotificationHandler } from "./base-notification.handler";
import { IUserPasswordNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../providers/notifications.service";
@Injectable()
export class UserPasswordHandler extends BaseNotificationHandler<IUserPasswordNotificationData> {
protected readonly logger = new Logger(UserPasswordHandler.name);
constructor(private readonly notificationsService: NotificationsService) {
super();
}
protected async handle(recipientId: string, data: IUserPasswordNotificationData, queryRunner: QueryRunner): Promise<void> {
await this.notificationsService.createUserPasswordNotification(recipientId, data, queryRunner);
}
protected getHandlerName(): string {
return "UserPassword";
}
}
@@ -82,3 +82,7 @@ export interface INewCriticismNotificationData extends IBaseNotificationData {
title: string;
fullName: string;
}
export interface IUserPasswordNotificationData extends IBaseNotificationData {
password: string;
}
@@ -5,34 +5,28 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { NOTIFICATION } from "./constants";
import { Notification } from "./entities/notification.entity";
import { BlogCommentHandler } from "./handlers/blog-comment.handler";
import { NotificationController } from "./notifications.controller";
import { LoggerModule } from "../logger/logger.module";
import {
AnnouncementHandler,
AnswerTicketHandler,
ApproveInvoiceHandler,
AssignTicketHandler,
BillInvoiceHandler,
BillInvoiceReminderHandler,
BlockServiceHandler,
BlogCommentHandler,
CreateInvoiceHandler,
CreateTicketHandler,
InvoiceOverdueHandler,
NewCriticismHandler,
NewCustomerHandler,
NewSubscriptionHandler,
NewTicketHandler,
NotificationHandlerFactory,
OtpHandler,
PaymentCancellationHandler,
PaymentReminderHandler,
RecurringInvoiceHandler,
ServiceReviewHandler,
UserLoginHandler,
WalletChargeHandler,
WalletDeductionHandler,
} from "./handlers";
import { NotificationController } from "./notifications.controller";
import { LoggerModule } from "../logger/logger.module";
} from "./handlers/admin-notifications.handler";
import { AnnouncementHandler } from "./handlers/announcement.handler";
import {
ApproveInvoiceHandler,
BillInvoiceHandler,
BillInvoiceReminderHandler,
CreateInvoiceHandler,
InvoiceOverdueHandler,
RecurringInvoiceHandler,
} from "./handlers/invoice.handler";
import { PaymentCancellationHandler, PaymentReminderHandler } from "./handlers/payment.handler";
import { BlockServiceHandler } from "./handlers/service.handler";
import { AnswerTicketHandler, AssignTicketHandler, CreateTicketHandler, NewTicketHandler } from "./handlers/ticket.handler";
import { UserPasswordHandler } from "./handlers/user-password.handler";
import { NotificationsService } from "./providers/notifications.service";
import { NotificationProcessor } from "./queue/notification.processor";
import { NotificationQueue } from "./queue/notification.queue";
@@ -40,6 +34,10 @@ import { NotificationRepository } from "./repositories/notifications.repository"
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
import { OtpHandler } from "./handlers/otp.handler";
import { UserLoginHandler } from "./handlers/user-login.handler";
import { WalletChargeHandler, WalletDeductionHandler } from "./handlers/wallet.handler";
@Module({
imports: [
@@ -92,6 +90,7 @@ import { UtilsModule } from "../utils/utils.module";
BlockServiceHandler,
PaymentReminderHandler,
PaymentCancellationHandler,
UserPasswordHandler,
],
controllers: [NotificationController],
exports: [NotificationRepository, NotificationsService, NotificationQueue],
@@ -23,6 +23,7 @@ import {
IServiceReviewNotificationData,
ISubscriptionNotificationData,
ITicketNotificationData,
IUserPasswordNotificationData,
IWalletNotificationData,
} from "../interfaces/ISendNotificationData";
import { NotificationRepository } from "../repositories/notifications.repository";
@@ -551,4 +552,15 @@ export class NotificationsService {
queryRunner,
);
}
//************************************************* */
async createUserPasswordNotification(recipientId: string, data: IUserPasswordNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.USER_PASSWORD_MESSAGE.replace("[password]", data.password);
await this.smsService.sendUserPasswordSms(data.userPhone, data.password);
return this.createNotification(
{ title: NotificationMessage.USER_PASSWORD, type: NotifType.USER_PASSWORD, message, recipientId },
queryRunner,
);
}
}
@@ -17,6 +17,7 @@ import {
IServiceReviewNotificationData,
ISubscriptionNotificationData,
ITicketNotificationData,
IUserPasswordNotificationData,
IWalletNotificationData,
} from "../interfaces/ISendNotificationData";
@@ -145,4 +146,8 @@ export class NotificationQueue {
async addPaymentCancellationNotification(recipientId: string, data: IPaymentNotificationData) {
await this.addNotificationJob(NotifType.PAYMENT_CANCELLATION, recipientId, data);
}
async addUserPasswordNotification(recipientId: string, data: IUserPasswordNotificationData) {
await this.addNotificationJob(NotifType.USER_PASSWORD, recipientId, data);
}
}
@@ -10,6 +10,7 @@ export enum NotifCategory {
export enum NotifType {
// Account category
USER_LOGIN = "USER_LOGIN",
USER_PASSWORD = "USER_PASSWORD",
ANNOUNCEMENT = "ANNOUNCEMENT",
// Finance category
@@ -50,6 +51,8 @@ export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifC
// Account category
[NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT },
[NotifType.ANNOUNCEMENT]: { fa: "اعلانات و اطلاعیه ها", category: NotifCategory.ACCOUNT },
//DO_NOT_CREATE_THIS_NOTIFICATION_IN_DATABASE
[NotifType.USER_PASSWORD]: { fa: "ارسال رمز عبور پیش‌فرض", category: NotifCategory.ACCOUNT },
// Finance category
[NotifType.PAYMENT_REMINDER]: { fa: "یادآوری پرداخت", category: NotifCategory.FINANCE },
+28 -5
View File
@@ -1,10 +1,9 @@
import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, MinLength } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsMobilePhone, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, Length, MinLength } from "class-validator";
import { AdminMessage, AuthMessage } from "../../../common/enums/message.enum";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
export class CreateAdminDto extends PickType(CompleteRegistrationDto, ["firstName", "lastName", "phone", "password"] as const) {
export class CreateAdminDto {
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.PASSWORD_FORMAT_INVALID })
@ApiProperty({ description: "repeat password", example: "12345678" })
@@ -16,14 +15,38 @@ export class CreateAdminDto extends PickType(CompleteRegistrationDto, ["firstNam
@ApiProperty({ description: "Email", example: "ma@gmail.com" })
email: 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;
@IsNotEmpty({ message: AdminMessage.ROLE_REQUIRED })
@IsUUID("4", { message: AdminMessage.ROLE_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Role id", example: "123e4567-e89b-12d3-a456-426614174000" })
roleId: string;
@IsNotEmpty({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "First name", example: "mahyar" })
firstName: string;
@IsNotEmpty({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "Last name", example: "jamshidi" })
lastName: string;
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.PASSWORD_FORMAT_INVALID })
@ApiProperty({ description: "password", example: "12S345SS678" })
@MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
password: string;
@IsOptional()
@IsNotEmpty({ message: AdminMessage.PROFILE_PIC_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: AdminMessage.PROFILE_PIC_URL })
@ApiPropertyOptional({ description: "Profile picture", example: "https://www.google.com" })
profilePic: string;
profilePic?: string;
}
+21 -4
View File
@@ -1,18 +1,35 @@
import { ApiProperty, PickType } from "@nestjs/swagger";
import { IsEnum, IsInt, IsNotEmpty, IsNumberString, IsString, Length } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsInt, IsMobilePhone, IsNotEmpty, IsNumberString, IsString, Length } from "class-validator";
import { IsNationalCode } from "../../../common/decorators/is-national-code.decorator";
import { AuthMessage, FinancialMessage } from "../../../common/enums/message.enum";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
import { GenderEnum } from "../enums/gender-type.enum";
import { NationalityEnum } from "../enums/nationality.enum";
export class CreateRealUserDto extends PickType(CompleteRegistrationDto, ["firstName", "lastName", "phone"]) {
export class CreateRealUserDto {
@IsNotEmpty({ message: FinancialMessage.GENDER_TYPE_REQUIRED })
@IsEnum(GenderEnum)
@ApiProperty({ enum: GenderEnum, description: "Gender type", example: GenderEnum.MALE })
gender: GenderEnum;
@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;
@IsNotEmpty({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "First name", example: "mahyar" })
firstName: string;
@IsNotEmpty({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "Last name", example: "jamshidi" })
lastName: string;
@IsNotEmpty({ message: FinancialMessage.FATHER_NAME_REQUIRED })
@IsString({ message: FinancialMessage.FATHER_NAME_INVALID })
@ApiProperty({ description: "Father name" })
+20 -7
View File
@@ -1,11 +1,24 @@
import { ApiProperty, ApiPropertyOptional, PartialType, PickType } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsNumberString, IsOptional, IsString, IsUrl, Length } from "class-validator";
import { IsNationalCode } from "../../../common/decorators/is-national-code.decorator";
import { AuthMessage, UserMessage } from "../../../common/enums/message.enum";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationDto, ["firstName", "lastName"] as const)) {
export class UpdateProfileDto {
@IsOptional()
@IsNotEmpty({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.FIRST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "First name", example: "mahyar" })
firstName?: string;
@IsOptional()
@IsNotEmpty({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@IsString({ message: AuthMessage.LAST_NAME_NOT_EMPTY })
@Length(2, 50, { message: AuthMessage.LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 })
@ApiProperty({ description: "Last name", example: "jamshidi" })
lastName?: string;
@IsOptional()
@IsNotEmpty({ message: UserMessage.USERNAME_NOT_EMPTY })
@Length(3, 50, { message: UserMessage.USERNAME_SHOULD_BE_BETWEEN_3_AND_50 })
@@ -17,7 +30,7 @@ export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationD
@IsNotEmpty({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
@IsString({ message: AuthMessage.BIRTH_DATE_NOT_EMPTY })
@ApiProperty({ description: "Birth date", example: "1403/01/01" })
birthDate: string;
birthDate?: string;
@IsOptional()
@IsNotEmpty({ message: AuthMessage.NATIONAL_NOT_EMPTY })
@@ -25,7 +38,7 @@ export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationD
@Length(10, 10, { message: AuthMessage.NATIONAL_CODE_INCORRECT })
@IsNationalCode({ message: AuthMessage.NATIONAL_CODE_INVALID })
@ApiProperty({ description: "iranian format (10 char)", example: "4569852169" })
nationalCode: string;
nationalCode?: string;
@ApiPropertyOptional({ description: "Profile picture", example: "https://www.google.com" })
@IsOptional()
@@ -47,8 +60,8 @@ export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationD
@Length(10, 10, { message: UserMessage.POSTAL_CODE_LENGTH })
postalCode?: string;
@ApiPropertyOptional({ description: "City id" })
@IsOptional()
@ApiPropertyOptional({ description: "City id" })
@IsNotEmpty({ message: UserMessage.CITY_REQUIRED })
cityId: string;
cityId?: string;
}
+3 -3
View File
@@ -50,13 +50,13 @@ export class User extends BaseEntity {
lastName: string;
@Column({ type: "varchar", length: 12, nullable: true })
birthDate: string;
birthDate?: string;
@Column({ type: "varchar", length: 100, unique: true, nullable: true })
nationalCode: string | null;
nationalCode?: string;
@Column({ type: "varchar", length: 100, nullable: true })
profilePic: string;
profilePic?: string;
@Column({ type: "boolean", default: false })
emailVerified: boolean;
+11 -7
View File
@@ -8,7 +8,6 @@ import { In, Not, QueryRunner } from "typeorm";
import { AuthMessage, CommonMessage, UserMessage } from "../../../common/enums/message.enum";
import { AddressService } from "../../address/providers/address.service";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
import { RequestOtpDto } from "../../auth/DTO/request-otp.dto";
import { VerifyOtpDto } from "../../auth/DTO/verify-otp.dto";
import { ReferralsService } from "../../referrals/providers/referrals.service";
@@ -201,7 +200,7 @@ export class UsersService {
const existPhone = await this.userRepository.findOneBy({ phone });
if (existPhone) throw new BadRequestException(UserMessage.PHONE_EXIST);
const existCode = await this.otpService.checkExistOtp(phone, "LOGIN");
const existCode = await this.otpService.checkExistOtp(phone, "LOGIN/REGISTER");
if (existCode) {
return {
message: AuthMessage.OTP_ALREADY_SENT,
@@ -209,7 +208,7 @@ export class UsersService {
};
}
const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN");
const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN/REGISTER");
//
await this.smsService.sendSmsVerifyCode(phone, otpCode);
this.logger.debug(`OTP sent to ${phone}: ${otpCode}`);
@@ -237,7 +236,7 @@ export class UsersService {
/************************************************************ */
async createUser(registerDto: CompleteRegistrationDto, hashedPassword: string, queryRunner: QueryRunner): Promise<User> {
async createUser(registerDto: VerifyOtpDto, hashedPassword: string, queryRunner: QueryRunner): Promise<User> {
const role = await queryRunner.manager.findOneBy(Role, { name: RoleEnum.USER });
if (!role) throw new BadRequestException(UserMessage.ROLE_NOT_FOUND);
@@ -247,7 +246,10 @@ export class UsersService {
// const existUser = await queryRunner.manager.findOneBy(User, { nationalCode: registerDto.nationalCode });
// if (existUser) throw new BadRequestException(UserMessage.NATIONAL_CODE_EXIST);
const userName = slugify(`${registerDto.firstName} ${Date.now().toString().slice(-5)}`, { lower: true, trim: true });
const userName = slugify(`u-${registerDto.phone}`, { lower: true, trim: true });
const defaultFirstName = "کاربر";
const defaultLastName = userName.slice(0, 5);
const { referralCode, ...userData } = registerDto;
const user = queryRunner.manager.create(User, {
@@ -255,6 +257,8 @@ export class UsersService {
userName,
password: hashedPassword,
roles: [role],
firstName: defaultFirstName,
lastName: defaultLastName,
});
await queryRunner.manager.save(user);
@@ -383,10 +387,10 @@ export class UsersService {
/************************************************************ */
private async checkUserLoginCredentialWithPhone(phone: string, otpCode: string, userId: string) {
const isValid = await this.otpService.verifyOtp(phone, otpCode, "LOGIN");
const isValid = await this.otpService.verifyOtp(phone, otpCode, "LOGIN/REGISTER");
if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP);
await this.otpService.delOtpFormCache(phone, "LOGIN");
await this.otpService.delOtpFormCache(phone, "LOGIN/REGISTER");
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
+1 -1
View File
@@ -1 +1 @@
export type OtpCacheKeyType = "LOGIN" | "REGISTER" | "FORGOT_PASSWORD" | "INVOICE_VERIFY";
export type OtpCacheKeyType = "LOGIN/REGISTER" | "FORGOT_PASSWORD" | "INVOICE_VERIFY";
+2 -1
View File
@@ -48,4 +48,5 @@ export type TemplateParams =
| "message"
| "blogTitle"
| "fullName"
| "mobile";
| "mobile"
| "password";
@@ -789,5 +789,35 @@ export class SmsService {
}
}
async sendUserPasswordSms(mobile: string, password: string) {
const smsData: ISmsVerifyBody = {
Parameters: [{ name: "password", value: password }],
Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_USER_PASSWORD,
};
try {
const { data } = await firstValueFrom(
this.httpService
.post<ISmsVerifyResponse>(
`${this.smsConfigs.API_URL}/send/verify`,
{ ...smsData },
{
headers: { "X-API-KEY": this.smsConfigs.API_KEY },
},
)
.pipe(
catchError((err: AxiosError) => {
this.logger.error("error in sending user password sms", err);
throw new InternalServerErrorException("error in sending user password sms");
}),
),
);
return data;
} catch (error) {
this.logger.error("error in sending sms", error);
}
}
//************************************************* */
}
@@ -2,3 +2,19 @@ export function truncateIfLong(str: string, maxLength = 30): string {
if (typeof str !== "string") return str;
return str.length > maxLength ? str.slice(0, maxLength) + "..." : str;
}
export function randomizeCase(str: string): string {
if (typeof str !== "string") return str;
return str
.split("")
.map((char) => {
// Only process alphabetic characters
if (/[a-zA-Z]/.test(char)) {
// 50% chance to make it uppercase, 50% lowercase
return Math.random() < 0.5 ? char.toLowerCase() : char.toUpperCase();
}
return char; // Keep non-alphabetic characters as they are
})
.join("");
}