feat: add email/phone change
This commit is contained in:
parent
bf60bf5901
commit
6cb3d0d6f3
@ -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
|
||||
|
||||
@ -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<GetAccountResponse> {
|
||||
return this.accountService.getAccount(data)
|
||||
}
|
||||
|
||||
@GrpcMethod('AccountService', 'InitEmailChange')
|
||||
public async initEmailChange(
|
||||
data: InitEmailChangeRequest
|
||||
): Promise<InitEmailChangeResponse> {
|
||||
return this.accountService.initEmailChange(data)
|
||||
}
|
||||
|
||||
@GrpcMethod('AccountService', 'ConfirmEmailChange')
|
||||
public async confirmEmailChange(
|
||||
data: ConfirmEmailChangeRequest
|
||||
): Promise<ConfirmEmailChangeResponse> {
|
||||
return this.accountService.confirmEmailChange(data)
|
||||
}
|
||||
|
||||
@GrpcMethod('AccountService', 'InitPhoneChange')
|
||||
public async initPhoneChange(
|
||||
data: InitPhoneChangeRequest
|
||||
): Promise<InitPhoneChangeResponse> {
|
||||
return this.accountService.initPhoneChange(data)
|
||||
}
|
||||
|
||||
@GrpcMethod('AccountService', 'ConfirmPhoneChange')
|
||||
public async confirmPhoneChange(
|
||||
data: ConfirmPhoneChangeRequest
|
||||
): Promise<ConfirmPhoneChangeResponse> {
|
||||
return this.accountService.confirmPhoneChange(data)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 } }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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<Account['phone']>) {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: { phone }
|
||||
})
|
||||
}
|
||||
|
||||
public findByEmail(email: NonNullable<Account['email']>) {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AllConfigs>,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export class OtpService {
|
||||
60 * 5
|
||||
)
|
||||
|
||||
return code
|
||||
return { code, hash }
|
||||
}
|
||||
|
||||
public async verify(
|
||||
|
||||
1
src/shared/utils/repositories/index.ts
Normal file
1
src/shared/utils/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './user.repository'
|
||||
29
src/shared/utils/repositories/user.repository.ts
Normal file
29
src/shared/utils/repositories/user.repository.ts
Normal file
@ -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<Account['phone']>) {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: { phone }
|
||||
})
|
||||
}
|
||||
|
||||
public findByEmail(email: NonNullable<Account['email']>) {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
}
|
||||
|
||||
public updateAccount(id: Account['id'], data: AccountUpdateInput) {
|
||||
return this.prismaService.account.update({
|
||||
where: { id },
|
||||
data
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user