Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions prisma/migrations/20260429062607_modify_schemas/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Warnings:

- You are about to drop the column `createdAt` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `deleted` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `deletedAt` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `firstName` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `tokenVersion` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `updatedAt` on the `users` table. All the data in the column will be lost.
- Added the required column `updated_at` to the `users` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "refresh_tokens" ALTER COLUMN "expires_at" DROP DEFAULT;

-- AlterTable
ALTER TABLE "users" DROP COLUMN "createdAt",
DROP COLUMN "deleted",
DROP COLUMN "deletedAt",
DROP COLUMN "firstName",
DROP COLUMN "lastName",
DROP COLUMN "tokenVersion",
DROP COLUMN "updatedAt",
ADD COLUMN "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "deleted_at" TIMESTAMP(3),
ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT,
ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "updated_at" TIMESTAMP NOT NULL;
33 changes: 17 additions & 16 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,18 @@ datasource db {
}

model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted Boolean @default(false)
deletedAt DateTime?
role Role @default(USER)
tokenVersion Int @default(1)

email String @unique
password String

firstName String?
lastName String?
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp()
deletedAt DateTime? @map("deleted_at")

email String @unique
password String
firstName String? @map("first_name")
lastName String? @map("last_name")
role Role @default(USER)
tokenVersion Int @default(1) @map("token_version")

refreshTokens RefreshToken[]

@@map("users")
Expand All @@ -43,10 +42,12 @@ enum Role {
model RefreshToken {
id String @id @db.Uuid
userId Int @map("user_id")
token String @map("token")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp()
expiresAt DateTime @default(now()) @map("expires_at") @db.Timestamp()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

token String
expiresAt DateTime @map("expires_at") @db.Timestamp()

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@map("refresh_tokens")
}
8 changes: 4 additions & 4 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,16 @@ describe('AuthService', () => {
).rejects.toThrow(NotFoundException)
})

it('should throw ForbiddenException if user is deleted', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ deleted: true })
it('should throw ForbiddenException if user is soft-deleted', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ deletedAt: new Date() })
await expect(
service.login({ email: 't@t.com', password: 'p' }),
).rejects.toThrow(ForbiddenException)
})

it('should throw ForbiddenException if password mismsatch', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
deleted: false,
deletedAt: null,
password: 'hashed',
})
;(argon.verify as jest.Mock).mockResolvedValue(false)
Expand All @@ -115,7 +115,7 @@ describe('AuthService', () => {
})

it('should generate token if successful', async () => {
const mockUser = { id: 1, deleted: false, password: 'hashed' }
const mockUser = { id: 1, deletedAt: null, password: 'hashed' }
mockPrisma.user.findUnique.mockResolvedValue(mockUser)
;(argon.verify as jest.Mock).mockResolvedValue(true)
mockTokenService.generateToken.mockResolvedValue({ accessToken: 'a' })
Expand Down
4 changes: 2 additions & 2 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export class AuthService {
})
// if user does not exist, throw exception
if (!user) throw new NotFoundException('User not found')
// check user is not active (deleted)
if (user.deleted) throw new ForbiddenException('User account inactive')
// check user is not active (soft-deleted)
if (user.deletedAt) throw new ForbiddenException('User account inactive')
// compare password
const pwMatches = await argon.verify(user.password, loginDto.password)
// if the password incorrect, throw exception
Expand Down
8 changes: 4 additions & 4 deletions src/auth/guard/auth.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,11 @@ describe('AuthGuard', () => {
)
})

it('should throw ForbiddenException if user is inactive (deleted)', async () => {
it('should throw ForbiddenException if user is inactive (soft-deleted)', async () => {
mockReflector.getAllAndOverride.mockReturnValue(false)
const context = mockContext({ authorization: 'Bearer valid_token' })
mockJwtService.verifyAsync.mockResolvedValue({ sub: 1, version: 1 })
mockPrisma.user.findUnique.mockResolvedValue({ deleted: true })
mockPrisma.user.findUnique.mockResolvedValue({ deletedAt: new Date() })

await expect(guard.canActivate(context)).rejects.toThrow(
new ForbiddenException('Your account is inactive'),
Expand All @@ -124,7 +124,7 @@ describe('AuthGuard', () => {
const context = mockContext({ authorization: 'Bearer valid_token' })
mockJwtService.verifyAsync.mockResolvedValue({ sub: 1, version: 1 }) // payload version 1
mockPrisma.user.findUnique.mockResolvedValue({
deleted: false,
deletedAt: null,
tokenVersion: 2,
}) // user version 2

Expand All @@ -149,7 +149,7 @@ describe('AuthGuard', () => {
email: 'test@test.com',
role: 'USER',
tokenVersion: 1,
deleted: false,
deletedAt: null,
})

const result = await guard.canActivate(context)
Expand Down
12 changes: 9 additions & 3 deletions src/auth/guard/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,17 @@ export class AuthGuard implements CanActivate {

const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { email: true, role: true, tokenVersion: true, deleted: true },
select: {
email: true,
role: true,
tokenVersion: true,
deletedAt: true,
},
})
if (!user) throw new NotFoundException('Your account not exists')
// check user is not active (deleted)
if (user.deleted) throw new ForbiddenException('Your account is inactive')
// check user is not active (soft-deleted)
if (user.deletedAt)
throw new ForbiddenException('Your account is inactive')

if (user.tokenVersion !== payload.version) {
throw new UnauthorizedException('Access token revoked')
Expand Down
8 changes: 4 additions & 4 deletions src/token/token.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ describe('TokenService', () => {
)
})

it('should throw ForbiddenException if user is deleted', async () => {
it('should throw ForbiddenException if user is soft-deleted', async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: 1,
deleted: true,
deletedAt: new Date(),
})

await expect(service.refreshToken(1, 'mockTokenId')).rejects.toThrow(
Expand All @@ -144,7 +144,7 @@ describe('TokenService', () => {
it('should throw UnauthorizedException on JsonWebTokenError', async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: 1,
deleted: false,
deletedAt: null,
})
mockPrismaService.refreshToken.delete.mockRejectedValue(
new JsonWebTokenError('Invalid token'),
Expand All @@ -160,7 +160,7 @@ describe('TokenService', () => {
id: 1,
email: 'test@test.com',
tokenVersion: 1,
deleted: false,
deletedAt: null,
} as User
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.refreshToken.delete.mockResolvedValue({})
Expand Down
5 changes: 3 additions & 2 deletions src/token/token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ export class TokenService {
where: { id: userId },
})
if (!user) throw new NotFoundException('User not exists')
// check user is not active (deleted)
if (user.deleted) throw new ForbiddenException('User account is inactive')
// check user is not active (soft-deleted)
if (user.deletedAt)
throw new ForbiddenException('User account is inactive')
// delete used refresh token from db
await this.prisma.refreshToken.delete({ where: { id: tokenId } })
// generate access token and refresh token
Expand Down
1 change: 0 additions & 1 deletion src/user/user-cleanup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export class UserCleanupService {

const usersToDelete = await this.prisma.user.findMany({
where: {
deleted: true,
deletedAt: {
lte: threshold,
},
Expand Down
18 changes: 10 additions & 8 deletions src/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ describe('UserService', () => {
describe('softDeleteUser', () => {
it('should soft delete user and revoke token', async () => {
mockOtpService.verifyCode.mockResolvedValue(true)
mockPrisma.user.update.mockResolvedValue({ id: 1, deleted: true })
mockPrisma.user.update.mockResolvedValue({
id: 1,
deletedAt: new Date(),
})
mockTokenService.revokeAllToken.mockResolvedValue({})

const result = await service.softDeleteUser('t@t.com', {
Expand All @@ -153,7 +156,6 @@ describe('UserService', () => {
expect(result).toEqual({
id: 1,
status: true,
deleted: true,
message: 'Soft deleted user',
})
expect(mockTokenService.revokeAllToken).toHaveBeenCalledWith(1)
Expand Down Expand Up @@ -190,17 +192,17 @@ describe('UserService', () => {
})

describe('reactivateUser', () => {
it('should reactivate deleted user and return tokens', async () => {
it('should reactivate soft-deleted user and return tokens', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'deleted@t.com',
deleted: true,
deletedAt: new Date(),
})
mockOtpService.verifyCode.mockResolvedValue(true)
mockPrisma.user.update.mockResolvedValue({
id: 1,
email: 'deleted@t.com',
deleted: false,
deletedAt: null,
})
mockTokenService.generateToken.mockResolvedValue({
accessToken: 'access-token',
Expand All @@ -219,13 +221,13 @@ describe('UserService', () => {
)
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { email: 'deleted@t.com' },
data: { deleted: false, deletedAt: null },
data: { deletedAt: null },
omit: { password: true },
})
expect(mockTokenService.generateToken).toHaveBeenCalledWith({
id: 1,
email: 'deleted@t.com',
deleted: false,
deletedAt: null,
})
expect(result).toEqual({
id: 1,
Expand All @@ -251,7 +253,7 @@ describe('UserService', () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'active@t.com',
deleted: false,
deletedAt: null,
})

await expect(
Expand Down
7 changes: 3 additions & 4 deletions src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,13 @@ export class UserService {
await this.otpService.verifyCode(email, dto.emailVerifiedCode, true)
const deletedUser = await this.prisma.user.update({
where: { email },
data: { deleted: true, deletedAt: new Date() },
data: { deletedAt: new Date() },
omit: { password: true },
})
await this.tokenService.revokeAllToken(deletedUser.id)
return {
id: deletedUser.id,
status: true,
deleted: deletedUser.deleted,
message: 'Soft deleted user',
}
} catch (error) {
Expand All @@ -122,11 +121,11 @@ export class UserService {
omit: { password: true },
})
if (!foundUser) throw new NotFoundException('User not found')
if (!foundUser.deleted) throw new ConflictException('User already active')
if (!foundUser.deletedAt) throw new ConflictException('User already active')
await this.otpService.verifyCode(dto.email, dto.emailVerifiedCode)
const reactivatedUser = await this.prisma.user.update({
where: { email: dto.email },
data: { deleted: false, deletedAt: null },
data: { deletedAt: null },
omit: { password: true },
})
const tokens = await this.tokenService.generateToken(reactivatedUser)
Expand Down
5 changes: 2 additions & 3 deletions test/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('UserController (e2e)', () => {
// Soft delete the user
await prisma.user.update({
where: { email },
data: { deleted: true },
data: { deletedAt: new Date() },
})

await request(app.getHttpServer())
Expand Down Expand Up @@ -179,7 +179,7 @@ describe('UserController (e2e)', () => {
.expect(200)

const user = await prisma.user.findUnique({ where: { email } })
expect(user?.deleted).toBe(true)
expect(user?.deletedAt).not.toBeNull()
})
})

Expand Down Expand Up @@ -262,7 +262,6 @@ describe('UserController (e2e)', () => {
expect(res.body.refreshToken).toBeDefined()

const user = await prisma.user.findUnique({ where: { email } })
expect(user?.deleted).toBe(false)
expect(user?.deletedAt).toBeNull()
})

Expand Down
Loading