diff --git a/.env.example b/.env.example index f5743d7..1a04c3b 100644 --- a/.env.example +++ b/.env.example @@ -17,12 +17,12 @@ SMS_API_URL=https://api.sms.ir/v1 SMS_API_KEY="dasdas" SMS_PATTERN_OTP=82385 -SMTP_HOST= -SMTP_PORT= -SMTP_USER= -SMTP_PASS= - -MAIL_FROM= +SMTP_HOST=smtp.c1.liara.email +SMTP_PORT=587 +SMTP_USER=usernames +SMTP_PASS=password +MAIL_FROM=confirmation@asan-service.com +EMAIL_SECRET="secret key" REDIS_URI=redis://:@localhost:6379/0 CACHE_TTL=120000 diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 45cd3d8..7d369a1 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -52,6 +52,8 @@ export const enum AuthMessage { OTP_ALREADY_SENT = "کد یکبار مصرف قبلا ارسال شده است", TOO_MANY_REQUESTS = "تعداد درخواست های شما بیش از حد مجاز است", NOT_ADMIN = "شما دسترسی به بخش ادمین ندارید", + PHONE_SHOULD_BE_11_DIGIT = "شماره تلفن باید ۱۱ رقم باشد", + TIMESTAMP_NOT_EMPTY = "TIMESTAMP_NOT_EMPTY", } export const enum UserMessage { @@ -66,6 +68,12 @@ export const enum UserMessage { USER_GROUP_CREATED = "گروه کاربری با موفقیت ایجاد شد", USER_ID_SHOULD_BE_A_UUID = "شناسه کاربر باید یک UUID باشد", NATIONAL_CODE_EXIST = "کد ملی قبلا ثبت شده است", + PROFILE_PIC_URL = "آدرس عکس پروفایل باید یک آدرس یو آر ال باشد", + PROFILE_PIC_REQUIRED = "عکس پروفایل نمی‌تواند خالی باشد", + VERIFY_EMAIL_LINK_SENT = "لینک تایید ایمیل به ایمیل شما ارسال شد", + EMAIL_VERIFIED = "ایمیل شما تایید شد", + INVALID_EMAIL_TOKEN = "توکن ایمیل نامعتبر است", + EXPIRED_EMAIL_TOKEN = "توکن ایمیل منقضی شده است", } export const enum CommonMessage { @@ -391,6 +399,7 @@ export const enum DiscountMessage { export const enum EmailMessage { EMAIL_VERIFICATION = "تایید ایمیل", + EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد", } export const enum FinancialMessage { diff --git a/src/configs/mailer.config.ts b/src/configs/mailer.config.ts index a90f3c7..8858884 100644 --- a/src/configs/mailer.config.ts +++ b/src/configs/mailer.config.ts @@ -20,7 +20,7 @@ export function mailerConfig(): MailerAsyncOptions { }, template: { - dir: process.cwd() + "/templates", + dir: process.cwd() + "/src/templates", adapter: new HandlebarsAdapter(), options: { strict: true, diff --git a/src/modules/auth/DTO/complete-register.dto.ts b/src/modules/auth/DTO/complete-register.dto.ts index b05bde3..c96a5f1 100644 --- a/src/modules/auth/DTO/complete-register.dto.ts +++ b/src/modules/auth/DTO/complete-register.dto.ts @@ -6,6 +6,7 @@ import { AuthMessage } 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: "09123456789" }) phone: string; diff --git a/src/modules/auth/DTO/request-otp.dto.ts b/src/modules/auth/DTO/request-otp.dto.ts index a41358b..e623cbb 100644 --- a/src/modules/auth/DTO/request-otp.dto.ts +++ b/src/modules/auth/DTO/request-otp.dto.ts @@ -1,10 +1,11 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsMobilePhone, IsNotEmpty } from "class-validator"; +import { IsMobilePhone, IsNotEmpty, Length } from "class-validator"; import { AuthMessage } from "../../../common/enums/message.enum"; export class RequestOtpDto { @IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY }) + @Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT }) @IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT }) @ApiProperty({ description: "phone number", default: "09123456789" }) phone: string; diff --git a/src/modules/auth/DTO/verify-otp.dto.ts b/src/modules/auth/DTO/verify-otp.dto.ts index 116f768..1181730 100644 --- a/src/modules/auth/DTO/verify-otp.dto.ts +++ b/src/modules/auth/DTO/verify-otp.dto.ts @@ -5,6 +5,7 @@ import { AuthMessage } from "../../../common/enums/message.enum"; export class VerifyOtpDto { @IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY }) + @Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT }) @IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT }) @ApiProperty({ description: "phone number", default: "09123456789" }) phone: string; diff --git a/src/modules/users/DTO/create-user.dto.ts b/src/modules/users/DTO/create-user.dto.ts index 24656c2..12f0171 100644 --- a/src/modules/users/DTO/create-user.dto.ts +++ b/src/modules/users/DTO/create-user.dto.ts @@ -7,6 +7,7 @@ import { AuthMessage } from "../../../common/enums/message.enum"; export class CreateUserDto extends PartialType(CreateLegalUserDto) { @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: "09123456789" }) phone: string; diff --git a/src/modules/users/DTO/update-email.dto.ts b/src/modules/users/DTO/update-email.dto.ts new file mode 100644 index 0000000..bec949c --- /dev/null +++ b/src/modules/users/DTO/update-email.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty } from "class-validator"; + +import { AuthMessage } from "../../../common/enums/message.enum"; + +export class UpdateEmailDto { + @IsNotEmpty({ message: AuthMessage.EMAIL_NOT_EMPTY }) + @IsEmail({}, { message: AuthMessage.INVALID_EMAIL_FORMAT }) + @ApiProperty({ description: "Email", example: "ma@gmail.com" }) + email: string; +} diff --git a/src/modules/users/DTO/update-profile.dto.ts b/src/modules/users/DTO/update-profile.dto.ts index 931b6b2..da18290 100644 --- a/src/modules/users/DTO/update-profile.dto.ts +++ b/src/modules/users/DTO/update-profile.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty, PartialType, PickType } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; +import { ApiPropertyOptional, PartialType, PickType } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, IsUrl, Length } from "class-validator"; import { UserMessage } from "../../../common/enums/message.enum"; import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto"; @@ -9,6 +9,12 @@ export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationD @IsNotEmpty({ message: UserMessage.USERNAME_NOT_EMPTY }) @Length(3, 50, { message: UserMessage.USERNAME_SHOULD_BE_BETWEEN_3_AND_50 }) @IsString({ message: UserMessage.USERNAME_NOT_EMPTY }) - @ApiProperty({ description: "User name", example: "mamad24" }) + @ApiPropertyOptional({ description: "User name", example: "mamad24" }) userName?: string; + + @IsOptional() + @IsNotEmpty({ message: UserMessage.PROFILE_PIC_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: UserMessage.PROFILE_PIC_URL }) + @ApiPropertyOptional({ description: "Profile picture", example: "https://www.google.com" }) + profilePic?: string; } diff --git a/src/modules/users/DTO/verify-email-query.dto.ts b/src/modules/users/DTO/verify-email-query.dto.ts new file mode 100644 index 0000000..6913302 --- /dev/null +++ b/src/modules/users/DTO/verify-email-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, IsUUID } from "class-validator"; + +import { AuthMessage, UserMessage } from "../../../common/enums/message.enum"; + +export class EmailVerifyQueryDto { + @IsNotEmpty({ message: AuthMessage.TOKEN_NOT_EMPTY }) + @IsString({ message: AuthMessage.TOKEN_NOT_EMPTY }) + @ApiProperty({ description: "Token", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" }) + token: string; + + @IsNotEmpty({ message: UserMessage.USER_ID_SHOULD_BE_A_UUID }) + @IsUUID(4, { message: UserMessage.USER_ID_SHOULD_BE_A_UUID }) + @ApiProperty({ description: "User id", example: "f7b1b3b1-4b7b-4b7b-4b7b-4b7b4b7b4b7b" }) + userId: string; + + @IsNotEmpty({ message: AuthMessage.TIMESTAMP_NOT_EMPTY }) + ts: string; +} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 88e2408..057fb68 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -50,6 +50,9 @@ export class User extends BaseEntity { @Column({ type: "varchar", length: 100, nullable: true }) profilePic: string; + + @Column({ type: "boolean", default: false }) + emailVerified: boolean; //----------------------------------------- // @ManyToOne(() => Role, { eager: true, onDelete: "RESTRICT", nullable: false }) // role: Role; diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 18c6d39..d895656 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -1,4 +1,8 @@ +import { createHmac } from "node:crypto"; + import { BadRequestException, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { FastifyReply } from "fastify"; import slugify from "slugify"; import { In, Not, QueryRunner } from "typeorm"; @@ -13,6 +17,7 @@ import { import { AddressService } from "../../address/providers/address.service"; import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto"; import { UserSettingsService } from "../../settings/providers/user-settings.service"; +import { EmailService } from "../../utils/providers/email.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { PasswordService } from "../../utils/providers/password.service"; import { WalletsService } from "../../wallets/providers/wallets.service"; @@ -24,8 +29,10 @@ import { CreateRoleDto } from "../DTO/create-role.dto"; import { SearchAdminQueryDto } from "../DTO/search-admins-query.dto"; import { SearchCustomersDto } from "../DTO/search-customers.dto"; import { SearchRolesQueryDto } from "../DTO/search-roles.dto"; +import { UpdateEmailDto } from "../DTO/update-email.dto"; import { UpdateProfileDto } from "../DTO/update-profile.dto"; import { CreateUserGroupDto } from "../DTO/user-group.dto"; +import { EmailVerifyQueryDto } from "../DTO/verify-email-query.dto"; import { Role } from "../entities/role.entity"; import { User } from "../entities/user.entity"; import { RoleEnum } from "../enums/role.enum"; @@ -50,6 +57,8 @@ export class UsersService { private readonly realUserRepository: RealUserRepository, private readonly legalUserRepository: LegalUserRepository, private readonly addressService: AddressService, + private readonly emailService: EmailService, + private readonly configService: ConfigService, ) {} /************************************************************ */ @@ -94,14 +103,15 @@ export class UsersService { /************************************************************ */ async updateProfile(userId: string, updateProfileDto: UpdateProfileDto) { + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + // if (updateProfileDto.userName) { - // updateProfileDto.userName = slugify(updateProfileDto.userName, { lower: true, trim: true }); const existUserName = await this.userRepository.findOneBy({ userName: updateProfileDto.userName, id: Not(userId) }); if (existUserName) throw new BadRequestException(UserMessage.USERNAME_EXIST); } - const user = await this.userRepository.findOneBy({ id: userId }); - if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); await this.userRepository.save({ ...user, ...updateProfileDto }); // @@ -111,6 +121,54 @@ export class UsersService { } /************************************************************ */ + async updateEmail(userId: string, updateDto: UpdateEmailDto) { + const { email } = updateDto; + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + // + const existEmail = await this.userRepository.findOneBy({ email, id: Not(userId) }); + if (existEmail) throw new BadRequestException(UserMessage.EMAIL_EXIST); + // + const emailLink = await this.createEmailHashLink(user.id); + await this.emailService.sendVerificationEmail(email, emailLink, `${user.firstName} ${user.lastName}`); + + await this.userRepository.save({ ...user, email }); + + return { + message: UserMessage.VERIFY_EMAIL_LINK_SENT, + email, + }; + } + /************************************************************ */ + //TODO:fix this later + async verifyEmail(queryDto: EmailVerifyQueryDto, rep: FastifyReply) { + const { token, userId, ts } = queryDto; + + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + const secret = this.configService.getOrThrow("EMAIL_SECRET"); + + // Check if timestamp is within allowed time window (e.g., 24 hours) + const timestamp = parseInt(ts, 10); + if (Date.now() - timestamp > 24 * 60 * 60 * 1000) { + throw new BadRequestException(UserMessage.EXPIRED_EMAIL_TOKEN); + } + + const payload = `${user.id}:${timestamp}`; + const expectedHash = createHmac("sha256", secret).update(payload).digest("hex"); + + if (expectedHash !== token) throw new BadRequestException(UserMessage.INVALID_EMAIL_TOKEN); + + user.emailVerified = true; + await this.userRepository.save(user); + + // return { message: UserMessage.EMAIL_VERIFIED }; + return rep.redirect("https://google.com"); + } + + /************************************************************ */ + async findOneWithEmail(email: string): Promise { const user = await this.userRepository.findOneWithEmail(email); return user; @@ -446,5 +504,20 @@ export class UsersService { }; } + private async createEmailHashLink(userId: string) { + const secret = this.configService.getOrThrow("EMAIL_SECRET"); + const timestamp = Date.now(); + const payload = `${userId}:${timestamp}`; + + const hash = createHmac("sha256", secret).update(payload).digest("hex"); + + const url = new URL(`${this.configService.getOrThrow("SITE_URL")}/users/verify-email`); + url.searchParams.append("token", hash); + url.searchParams.append("userId", userId); + url.searchParams.append("ts", timestamp.toString()); + + return url.toString(); + } + /************************************************************ */ } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 316e665..67edb4c 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Query, Res } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FastifyReply } from "fastify"; import { CheckValidityDTO } from "./DTO/check-validity.dto"; import { CreateAdminDto } from "./DTO/create-admin.dto"; @@ -9,9 +10,10 @@ import { CreateRoleDto } from "./DTO/create-role.dto"; import { SearchAdminQueryDto } from "./DTO/search-admins-query.dto"; import { SearchCustomersDto } from "./DTO/search-customers.dto"; import { SearchRolesQueryDto } from "./DTO/search-roles.dto"; +import { UpdateEmailDto } from "./DTO/update-email.dto"; import { UpdateProfileDto } from "./DTO/update-profile.dto"; import { CreateUserGroupDto } from "./DTO/user-group.dto"; -import { User } from "./entities/user.entity"; +import { EmailVerifyQueryDto } from "./DTO/verify-email-query.dto"; import { PermissionEnum } from "./enums/permission.enum"; import { UsersService } from "./providers/users.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; @@ -28,8 +30,8 @@ export class UsersController { @AuthGuards() @ApiOperation({ summary: "Get user profile" }) @Get("me") - getMe(@UserDec() user: User) { - return this.usersService.getMe(user.id); + getMe(@UserDec("id") userId: string) { + return this.usersService.getMe(userId); } /************************************************************ */ @@ -37,8 +39,8 @@ export class UsersController { @AuthGuards() @ApiOperation({ summary: "Get user financial info" }) @Get("me/financial-info") - getFinancialInfo(@UserDec() user: User) { - return this.usersService.getMyFinancialInfo(user.id); + getFinancialInfo(@UserDec("id") userId: string) { + return this.usersService.getMyFinancialInfo(userId); } /************************************************************ */ @@ -46,8 +48,22 @@ export class UsersController { @AuthGuards() @ApiOperation({ summary: "Update user profile" }) @Patch("update-profile") - updateProfile(@Body() updateProfileDto: UpdateProfileDto, @UserDec() user: User) { - return this.usersService.updateProfile(user.id, updateProfileDto); + updateProfile(@Body() updateProfileDto: UpdateProfileDto, @UserDec("id") userId: string) { + return this.usersService.updateProfile(userId, updateProfileDto); + } + + @AuthGuards() + @ApiOperation({ summary: "Update user email" }) + @Patch("update-email") + updateEmail(@Body() updateEmailDto: UpdateEmailDto, @UserDec("id") userId: string) { + return this.usersService.updateEmail(userId, updateEmailDto); + } + + @ApiOperation({ summary: "Verify email" }) + @HttpCode(HttpStatus.PERMANENT_REDIRECT) + @Get("verify-email") + verifyEmail(@Query() queryDto: EmailVerifyQueryDto, @Res({ passthrough: true }) rep: FastifyReply) { + return this.usersService.verifyEmail(queryDto, rep); } /************************************************************ */ @@ -56,8 +72,8 @@ export class UsersController { @ApiOperation({ summary: "Check validity of user field" }) @HttpCode(HttpStatus.OK) @Post("check-validity") - checkValidity(@Body() checkValidityDto: CheckValidityDTO, @UserDec() user: User) { - return this.usersService.checkValidity(checkValidityDto, user.id); + checkValidity(@Body() checkValidityDto: CheckValidityDTO, @UserDec("id") userId: string) { + return this.usersService.checkValidity(checkValidityDto, userId); } /************************************************************ */ diff --git a/src/modules/utils/providers/email.service.ts b/src/modules/utils/providers/email.service.ts index ec44f0e..b696b47 100644 --- a/src/modules/utils/providers/email.service.ts +++ b/src/modules/utils/providers/email.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; import { MailerService } from "@nestjs-modules/mailer"; +import { SentMessageInfo } from "nodemailer"; import { EmailMessage } from "../../../common/enums/message.enum"; @@ -8,12 +9,12 @@ export class EmailService { private readonly logger = new Logger(EmailService.name); constructor(private readonly mailerService: MailerService) {} - async sendEmail(to: string, subject: string, link: string, fullName: string) { + async sendVerificationEmail(to: string, link: string, fullName: string): Promise { try { const emailInfo = await this.mailerService.sendMail({ to: to, - // from: "noreply@nestjs.com", - subject: subject, + + subject: EmailMessage.EMAIL_VERIFICATION, template: "email-verify", context: { title: EmailMessage.EMAIL_VERIFICATION, @@ -24,7 +25,7 @@ export class EmailService { return emailInfo; } catch (error) { this.logger.error("Email sending failed:", error); - return false; + throw new InternalServerErrorException(EmailMessage.EMAIL_SENDING_FAILED); } } } diff --git a/src/templates/email-verify.hbs b/src/templates/email-verify.hbs index 4a6f02d..1abf874 100644 --- a/src/templates/email-verify.hbs +++ b/src/templates/email-verify.hbs @@ -22,10 +22,10 @@

به DanakCorp خوش آمدید! لطفاً برای تأیید ایمیل خود روی دکمه زیر کلیک کنید:

- تأیید ایمیل + تأیید ایمیل

اگر دکمه بالا کار نمی‌کند، می‌توانید از لینک زیر استفاده کنید:

-

{{verificationLink}}

+

{{link}}

اگر شما این درخواست را ارسال نکرده‌اید، لطفاً این ایمیل را نادیده بگیرید.