diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41c4b48..7b65ebd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,8 @@ model Account { isPhoneVerified Boolean @default(false) @map("is_phone_verified") isEmailVerified Boolean @default(false) @map("is_email_verified") + pendingContactChanges PendingContactChange[] + role Role @default(USER) createdAt DateTime @default(now()) @map("created_at") @@ -25,6 +27,24 @@ model Account { @@map("accounts") } +model PendingContactChange { + id String @id @default(nanoid()) + + type String + value String + codeHash String @map("code_hash") + expiresAt DateTime @map("expires_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + accountId String @map("account_id") + + @@unique([accountId, type]) + @@map("pending_contact_changes") +} + enum Role { ADMIN USER diff --git a/src/modules/account/account.controller.ts b/src/modules/account/account.controller.ts index 67d53d0..8f9779c 100644 --- a/src/modules/account/account.controller.ts +++ b/src/modules/account/account.controller.ts @@ -1,8 +1,16 @@ import { Controller } from '@nestjs/common' import { GrpcMethod } from '@nestjs/microservices' import type { + ConfirmEmailChangeRequest, + ConfirmEmailChangeResponse, + ConfirmPhoneChangeRequest, + ConfirmPhoneChangeResponse, GetAccountRequest, - GetAccountResponse + GetAccountResponse, + InitEmailChangeRequest, + InitEmailChangeResponse, + InitPhoneChangeRequest, + InitPhoneChangeResponse } from '@teacinema/contracts/gen/account' import { AccountService } from './account.service' @@ -17,4 +25,32 @@ export class AccountController { ): Promise { return this.accountService.getAccount(data) } + + @GrpcMethod('AccountService', 'InitEmailChange') + public async initEmailChange( + data: InitEmailChangeRequest + ): Promise { + return this.accountService.initEmailChange(data) + } + + @GrpcMethod('AccountService', 'ConfirmEmailChange') + public async confirmEmailChange( + data: ConfirmEmailChangeRequest + ): Promise { + return this.accountService.confirmEmailChange(data) + } + + @GrpcMethod('AccountService', 'InitPhoneChange') + public async initPhoneChange( + data: InitPhoneChangeRequest + ): Promise { + return this.accountService.initPhoneChange(data) + } + + @GrpcMethod('AccountService', 'ConfirmPhoneChange') + public async confirmPhoneChange( + data: ConfirmPhoneChangeRequest + ): Promise { + return this.accountService.confirmPhoneChange(data) + } } diff --git a/src/modules/account/account.module.ts b/src/modules/account/account.module.ts index aa3115c..bc97a8f 100644 --- a/src/modules/account/account.module.ts +++ b/src/modules/account/account.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common' import { AccountRepository } from '@/modules/account/account.repository' +import { OtpService } from '@/modules/otp/otp.service' +import { UserRepository } from '@/shared/utils/repositories' import { AccountController } from './account.controller' import { AccountService } from './account.service' @Module({ controllers: [AccountController], - providers: [AccountService, AccountRepository] + providers: [AccountService, AccountRepository, UserRepository, OtpService] }) export class AccountModule {} diff --git a/src/modules/account/account.repository.ts b/src/modules/account/account.repository.ts index f4c8918..29a825f 100644 --- a/src/modules/account/account.repository.ts +++ b/src/modules/account/account.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common' +import { Account } from '@prisma/generated/client' import { PrismaService } from '@/infra/prisma/prisma.service' @@ -9,4 +10,38 @@ export class AccountRepository { public findById(id: string) { return this.prismaService.account.findUnique({ where: { id } }) } + + public findContactChange(accountId: Account['id'], type: 'email' | 'phone') { + return this.prismaService.pendingContactChange.findUnique({ + where: { accountId_type: { accountId, type } } + }) + } + + public upsertPendingChange(data: { + accountId: Account['id'] + type: 'email' | 'phone' + value: string + codeHash: string + expiresAt: Date + }) { + return this.prismaService.pendingContactChange.upsert({ + where: { + accountId_type: { + accountId: data.accountId, + type: data.type + } + }, + create: data, + update: data + }) + } + + public deletePendingChange( + accountId: Account['id'], + type: 'email' | 'phone' + ) { + return this.prismaService.pendingContactChange.delete({ + where: { accountId_type: { accountId, type } } + }) + } } diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 317714e..47bef56 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -2,16 +2,27 @@ import { Injectable } from '@nestjs/common' import { RpcException } from '@nestjs/microservices' import { Role } from '@prisma/generated/enums' import { convertEnum, RpcStatus } from '@teacinema/common' -import type { +import { + ConfirmEmailChangeRequest, + ConfirmPhoneChangeRequest, GetAccountRequest, - GetAccountResponse + GetAccountResponse, + InitEmailChangeRequest, + InitPhoneChangeRequest } from '@teacinema/contracts/gen/account' +import { OtpService } from '@/modules/otp/otp.service' +import { UserRepository } from '@/shared/utils/repositories' + import { AccountRepository } from './account.repository' @Injectable() export class AccountService { - public constructor(private readonly accountRepository: AccountRepository) {} + public constructor( + private readonly accountRepository: AccountRepository, + private readonly userRepository: UserRepository, + private readonly otpService: OtpService + ) {} public async getAccount( data: GetAccountRequest @@ -36,4 +47,132 @@ export class AccountService { role: convertEnum(Role, account.role) } } + + public async initEmailChange(data: InitEmailChangeRequest) { + const { email, userId } = data + + const existing = await this.userRepository.findByEmail(email) + if (existing) { + throw new RpcException({ + code: RpcStatus.ALREADY_EXISTS, + details: 'Email already in use' + }) + } + + const { code, hash } = await this.otpService.send(email, 'email') + console.log('CODE', code) + await this.accountRepository.upsertPendingChange({ + accountId: userId, + type: 'email', + value: email, + codeHash: hash, + expiresAt: new Date(Date.now() + 5 * 60 * 1000) + }) + + return { ok: true } + } + + public async confirmEmailChange(data: ConfirmEmailChangeRequest) { + const { code, userId, email } = data + + const pending = await this.accountRepository.findContactChange( + userId, + 'email' + ) + + if (!pending) { + throw new RpcException({ + code: RpcStatus.NOT_FOUND, + details: 'No pending request' + }) + } + + if (pending.value !== email) { + throw new RpcException({ + code: RpcStatus.INVALID_ARGUMENTS, + details: 'Email mismatch' + }) + } + + if (pending.expiresAt < new Date()) { + throw new RpcException({ + code: RpcStatus.NOT_FOUND, + details: 'Code expired' + }) + } + const newEmail = pending.value + + await this.otpService.verify(newEmail, code, 'email') + await this.userRepository.updateAccount(userId, { + email: newEmail, + isEmailVerified: true + }) + await this.accountRepository.deletePendingChange(userId, 'email') + + return { ok: true } + } + + public async initPhoneChange(data: InitPhoneChangeRequest) { + const { phone, userId } = data + + const existing = await this.userRepository.findByPhone(phone) + if (existing) { + throw new RpcException({ + code: RpcStatus.ALREADY_EXISTS, + details: 'Phone already in use' + }) + } + + const { code, hash } = await this.otpService.send(phone, 'phone') + console.log('CODE', code) + await this.accountRepository.upsertPendingChange({ + accountId: userId, + type: 'phone', + value: phone, + codeHash: hash, + expiresAt: new Date(Date.now() + 5 * 60 * 1000) + }) + + return { ok: true } + } + + public async confirmPhoneChange(data: ConfirmPhoneChangeRequest) { + const { code, userId, phone } = data + + const pending = await this.accountRepository.findContactChange( + userId, + 'phone' + ) + + if (!pending) { + throw new RpcException({ + code: RpcStatus.NOT_FOUND, + details: 'No pending request' + }) + } + + if (pending.value !== phone) { + throw new RpcException({ + code: RpcStatus.INVALID_ARGUMENTS, + details: 'Phone mismatch' + }) + } + + if (pending.expiresAt < new Date()) { + throw new RpcException({ + code: RpcStatus.NOT_FOUND, + details: 'Code expired' + }) + } + const newPhone = pending.value + + await this.otpService.verify(newPhone, code, 'phone') + await this.userRepository.updateAccount(userId, { + phone: newPhone, + isPhoneVerified: true + }) + await this.accountRepository.deletePendingChange(userId, 'phone') + + return { ok: true } + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index c4848d4..1b19603 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -4,6 +4,7 @@ import { PassportModule } from '@teacinema/passport' import { getPassportConfig } from '@/config' import { OtpService } from '@/modules/otp/otp.service' +import { UserRepository } from '@/shared/utils/repositories' import { AuthController } from './auth.controller' import { AuthRepository } from './auth.repository' @@ -17,6 +18,6 @@ import { AuthService } from './auth.service' }) ], controllers: [AuthController], - providers: [AuthService, AuthRepository, OtpService] + providers: [AuthService, AuthRepository, UserRepository, OtpService] }) export class AuthModule {} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 8c13bb2..7cb7e02 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -1,9 +1,5 @@ import { Injectable } from '@nestjs/common' -import { Account } from '@prisma/generated/client' -import { - AccountCreateInput, - AccountUpdateInput -} from '@prisma/generated/models/Account' +import { AccountCreateInput } from '@prisma/generated/models/Account' import { PrismaService } from '@/infra/prisma/prisma.service' @@ -11,26 +7,7 @@ import { PrismaService } from '@/infra/prisma/prisma.service' export class AuthRepository { public constructor(private readonly prismaService: PrismaService) {} - public findByPhone(phone: NonNullable) { - return this.prismaService.account.findUnique({ - where: { phone } - }) - } - - public findByEmail(email: NonNullable) { - return this.prismaService.account.findUnique({ - where: { email } - }) - } - public createAccount(data: AccountCreateInput) { return this.prismaService.account.create({ data }) } - - public updateAccount(id: Account['id'], data: AccountUpdateInput) { - return this.prismaService.account.update({ - where: { id }, - data - }) - } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index e17c329..60e81b8 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -15,6 +15,7 @@ import { PassportService } from '@teacinema/passport' import { AllConfigs } from '@/config' import { OtpService } from '@/modules/otp/otp.service' +import { UserRepository } from '@/shared/utils/repositories' import { AuthRepository } from './auth.repository' @@ -26,6 +27,7 @@ export class AuthService { public constructor( private readonly configService: ConfigService, private readonly authRepository: AuthRepository, + private readonly userRepository: UserRepository, private readonly otpService: OtpService, private readonly passportService: PassportService ) { @@ -45,9 +47,9 @@ export class AuthService { const isEmailType = type === 'email' if (isPhoneType) { - account = await this.authRepository.findByPhone(identifier) + account = await this.userRepository.findByPhone(identifier) } else { - account = await this.authRepository.findByEmail(identifier) + account = await this.userRepository.findByEmail(identifier) } if (!account) { @@ -75,8 +77,8 @@ export class AuthService { const isEmailType = type === 'email' const account = isPhoneType - ? await this.authRepository.findByPhone(identifier) - : await this.authRepository.findByEmail(identifier) + ? await this.userRepository.findByPhone(identifier) + : await this.userRepository.findByEmail(identifier) if (!account) { throw new RpcException({ @@ -86,13 +88,13 @@ export class AuthService { } if (isPhoneType && !account.isPhoneVerified) { - await this.authRepository.updateAccount(account.id, { + await this.userRepository.updateAccount(account.id, { isPhoneVerified: true }) } if (isEmailType && !account.isEmailVerified) { - await this.authRepository.updateAccount(account.id, { + await this.userRepository.updateAccount(account.id, { isEmailVerified: true }) } diff --git a/src/modules/otp/otp.service.ts b/src/modules/otp/otp.service.ts index d5bebfc..2e7cfdb 100644 --- a/src/modules/otp/otp.service.ts +++ b/src/modules/otp/otp.service.ts @@ -19,7 +19,7 @@ export class OtpService { 60 * 5 ) - return code + return { code, hash } } public async verify( diff --git a/src/shared/utils/repositories/index.ts b/src/shared/utils/repositories/index.ts new file mode 100644 index 0000000..ced7014 --- /dev/null +++ b/src/shared/utils/repositories/index.ts @@ -0,0 +1 @@ +export * from './user.repository' diff --git a/src/shared/utils/repositories/user.repository.ts b/src/shared/utils/repositories/user.repository.ts new file mode 100644 index 0000000..33a94eb --- /dev/null +++ b/src/shared/utils/repositories/user.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common' +import { Account } from '@prisma/generated/client' +import { AccountUpdateInput } from '@prisma/generated/models/Account' + +import { PrismaService } from '@/infra/prisma/prisma.service' + +@Injectable() +export class UserRepository { + constructor(private readonly prismaService: PrismaService) {} + + public findByPhone(phone: NonNullable) { + return this.prismaService.account.findUnique({ + where: { phone } + }) + } + + public findByEmail(email: NonNullable) { + return this.prismaService.account.findUnique({ + where: { email } + }) + } + + public updateAccount(id: Account['id'], data: AccountUpdateInput) { + return this.prismaService.account.update({ + where: { id }, + data + }) + } +} diff --git a/yarn.lock b/yarn.lock index 89fd194..1caa01b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1383,9 +1383,9 @@ integrity sha512-q3DURJbSk3k8MNWFIYaSM4LEcBgPbWa+HJmBz/nzYT4kuYitJVSXxpZ97kr0Ea+81AZwAU3JhQKlkK0SBWUi0A== "@teacinema/contracts@^1.0.0": - version "1.0.6" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.6/contracts-1.0.6.tgz#7aa97d4c3081b791908c90d2a12d771b5fb79bd5" - integrity sha512-2IU2S9hWtKeMpn4zRS9pJAaSryIODrRjS1q9QezQOXF6554vtQlMVr0fLVTGhNmXlbRfuje0f6HggApqPDFMaA== + version "1.0.7" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.7/contracts-1.0.7.tgz#2c75a3118e4126d30f5c8193c4518bac3f1812ff" + integrity sha512-lE14PO/yBphYCu0BGFGyGAyvJ74v+ACz34MXA3fFo/PwThPVpudBReSG6oRyUACRybkN882GKXFU6LrJXvGbrQ== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0"