feat: add email/phone change

This commit is contained in:
Sergey Krylov 2026-02-11 05:57:30 +03:00
parent bf60bf5901
commit 6cb3d0d6f3
12 changed files with 282 additions and 40 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 {}

View File

@ -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 } }
})
}
}

View File

@ -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 }
}
}

View File

@ -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 {}

View File

@ -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
})
}
}

View File

@ -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
})
}

View File

@ -19,7 +19,7 @@ export class OtpService {
60 * 5
)
return code
return { code, hash }
}
public async verify(

View File

@ -0,0 +1 @@
export * from './user.repository'

View 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
})
}
}

View File

@ -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"