Skip to content

Commit

Permalink
feat(api): Added support for changing email of users
Browse files Browse the repository at this point in the history
  • Loading branch information
rayaanoidPrime authored and rajdip-b committed May 20, 2024
1 parent 11244a2 commit adc623a
Show file tree
Hide file tree
Showing 5 changed files with 321 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "UserEmailChange" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"newEmail" TEXT NOT NULL,
"otp" TEXT NOT NULL,
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "UserEmailChange_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserEmailChange_userId_key" ON "UserEmailChange"("userId");
10 changes: 10 additions & 0 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,13 @@ model ChangeNotificationSocketMap {
@@index([environmentId, socketId])
}

model UserEmailChange {
id String @id @default(cuid())
userId String
newEmail String
otp String
createdOn DateTime @default(now())
@@unique([userId])
}
13 changes: 13 additions & 0 deletions apps/api/src/user/controller/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,17 @@ export class UserController {
async createUser(@Body() dto: CreateUserDto) {
return await this.userService.createUser(dto)
}

@Post('validate-otp/:userId')
async validateOtp(
@Param('userId') userId: User['id'],
@Query('otp') otp: string
) {
return await this.userService.validateOtp(userId, otp.trim())
}

@Post('resend-otp/:userId')
async resendOtp(@Param('userId') userId: User['id']) {
return await this.userService.resendOtp(userId)
}
}
116 changes: 115 additions & 1 deletion apps/api/src/user/service/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common'
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
Logger,
UnauthorizedException
} from '@nestjs/common'
import { UpdateUserDto } from '../dto/update.user/update.user'
import { AuthProvider, User } from '@prisma/client'
import { PrismaService } from '../../prisma/prisma.service'
Expand All @@ -8,10 +15,12 @@ import {
MAIL_SERVICE
} from '../../mail/services/interface.service'
import createUser from '../../common/create-user'
import { randomUUID } from 'crypto'

@Injectable()
export class UserService {
private readonly log = new Logger(UserService.name)
private readonly OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes

constructor(
private readonly prisma: PrismaService,
Expand All @@ -32,6 +41,28 @@ export class UserService {
profilePictureUrl: dto?.profilePictureUrl,
isOnboardingFinished: dto.isOnboardingFinished
}
if (dto?.email) {
const userwithEmail = await this.prisma.user.findFirst({
where: {
email: dto.email
}
})

if (userwithEmail) {
throw new ConflictException('User with this email already exists')
}

const otp = await this.prisma.userEmailChange.create({
data: {
newEmail: dto.email,
userId: user.id,
otp: randomUUID().slice(0, 6).toUpperCase()
}
})

await this.mailService.sendOtp(dto.email, otp.otp)
}

this.log.log(`Updating user ${user.id} with data ${dto}`)
const updatedUser = await this.prisma.user.update({
where: {
Expand All @@ -51,6 +82,29 @@ export class UserService {
isActive: dto.isActive,
isOnboardingFinished: dto.isOnboardingFinished
}

if (dto.email) {
const userwithEmail = await this.prisma.user.findFirst({
where: {
email: dto.email
}
})

if (userwithEmail) {
throw new ConflictException('User with this email already exists')
}

const otp = await this.prisma.userEmailChange.create({
data: {
newEmail: dto.email,
userId: userId,
otp: randomUUID().slice(0, 6).toUpperCase()
}
})

await this.mailService.sendOtp(dto.email, otp.otp)
}

this.log.log(`Updating user ${userId} with data ${dto}`)
return await this.prisma.user.update({
where: {
Expand All @@ -60,6 +114,66 @@ export class UserService {
})
}

async validateOtp(userId: User['id'], otp: string): Promise<User> {
const user = await this.getUserById(userId)
if (!user) {
throw new BadRequestException(`User ${userId} does not exist`)
}

const userEmailChange = await this.prisma.userEmailChange.findUnique({
where: {
otp: otp,
userId: userId,
createdOn: {
gt: new Date(new Date().getTime() - this.OTP_EXPIRY)
}
}
})

if (!userEmailChange) {
this.log.log(`OTP expired or invalid`)
throw new UnauthorizedException('Invalid or expired OTP')
}

await this.prisma.userEmailChange.delete({
where: {
userId: userId,
otp: otp
}
})

this.log.log(
`Changing email to ${userEmailChange.newEmail} for user ${userId}`
)
return await this.prisma.user.update({
where: {
id: userId
},
data: {
email: userEmailChange.newEmail
}
})
}

async resendOtp(userId: User['id']) {
const user = await this.getUserById(userId)
if (!user) {
throw new BadRequestException(`User ${userId} does not exist`)
}

const newOtp = await this.prisma.userEmailChange.update({
where: {
userId: userId
},
data: {
otp: randomUUID().slice(0, 6).toUpperCase(),
createdOn: new Date()
}
})

await this.mailService.sendOtp(newOtp.newEmail, newOtp.otp)
}

async getUserById(userId: string) {
return await this.prisma.user.findUnique({
where: {
Expand Down
170 changes: 170 additions & 0 deletions apps/api/src/user/user.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,176 @@ describe('User Controller Tests', () => {
expect(result.statusCode).toEqual(204)
})

it('should send otp when user changes email', async () => {
const result = await app.inject({
method: 'PUT',
url: `/user`,
headers: {
'x-e2e-user-email': regularUser.email
},
payload: {
email: '[email protected]'
}
})

expect(result.statusCode).toEqual(200)
expect(JSON.parse(result.body)).toEqual({
...regularUser
})

const userEmailChange = await prisma.userEmailChange.findMany({
where: {
userId: regularUser.id,
newEmail: '[email protected]'
}
})

expect(userEmailChange.length).toEqual(1)
})

it('should send otp when admin changes an user email', async () => {
const result = await app.inject({
method: 'PUT',
url: `/user/${regularUser.id}`,
headers: {
'x-e2e-user-email': adminUser.email
},
payload: {
email: '[email protected]'
}
})

expect(result.statusCode).toEqual(200)
expect(JSON.parse(result.body)).toEqual({
...regularUser
})

const userEmailChange = await prisma.userEmailChange.findMany({
where: {
userId: regularUser.id,
newEmail: '[email protected]'
}
})

expect(userEmailChange.length).toEqual(1)
})

it('should give error when new email is used by an existing user', async () => {
const result = await app.inject({
method: 'PUT',
url: `/user`,
headers: {
'x-e2e-user-email': regularUser.email
},
payload: {
email: '[email protected]'
}
})

expect(result.statusCode).toEqual(409)
})

it('should validate OTP successfully', async () => {
await prisma.userEmailChange.create({
data: {
newEmail: '[email protected]',
userId: regularUser.id,
otp: '123456'
}
})

const result = await app.inject({
method: 'POST',
url: `/user/validate-otp/${regularUser.id}`,
query: {
otp: '123456'
},
headers: {
'x-e2e-user-email': regularUser.email
}
})

expect(result.statusCode).toEqual(201)
expect(JSON.parse(result.body)).toEqual({
...regularUser,
email: '[email protected]'
})

const updatedUser = await prisma.user.findUnique({
where: {
id: regularUser.id
}
})

expect(updatedUser.email).toEqual('[email protected]')
})

it('should fail to validate expired or invalid OTP', async () => {
await prisma.userEmailChange.create({
data: {
newEmail: '[email protected]',
userId: regularUser.id,
otp: '123456',
createdOn: new Date(new Date().getTime() - (5 * 60 * 1000 + 1)) // Expired OTP
}
})

const result = await app.inject({
method: 'POST',
url: `/user/validate-otp/${regularUser.id}`,
query: {
otp: '123456'
},
headers: {
'x-e2e-user-email': regularUser.email
}
})

expect(result.statusCode).toEqual(401)
expect(JSON.parse(result.body)).toEqual({
message: 'Invalid or expired OTP',
error: 'Unauthorized',
statusCode: 401
})

const nonUpdatedUser = await prisma.user.findUnique({
where: {
id: regularUser.id
}
})

expect(nonUpdatedUser.email).toEqual('[email protected]')
})

it('should resend OTP successfully', async () => {
await prisma.userEmailChange.create({
data: {
newEmail: '[email protected]',
userId: regularUser.id,
otp: '123456'
}
})

const result = await app.inject({
method: 'POST',
url: `/user/resend-otp/${regularUser.id}`,
headers: {
'x-e2e-user-email': regularUser.email
}
})

expect(result.statusCode).toEqual(201)

const updatedEmailChange = await prisma.userEmailChange.findFirst({
where: {
userId: regularUser.id,
newEmail: '[email protected]'
}
})

expect(updatedEmailChange.otp).not.toEqual('123456')
})

// test('user should be able to delete their own account', async () => {
// const result = await app.inject({
// method: 'DELETE',
Expand Down

0 comments on commit adc623a

Please sign in to comment.