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
1 change: 1 addition & 0 deletions src/common/pagination/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './pagination-query.dto'
14 changes: 14 additions & 0 deletions src/common/pagination/dto/pagination-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Type } from 'class-transformer'
import { IsOptional, IsPositive } from 'class-validator'

export class PaginationQueryDto {
@Type(() => Number)
@IsOptional()
@IsPositive()
limit?: number

@Type(() => Number)
@IsOptional()
@IsPositive()
page?: number
}
2 changes: 2 additions & 0 deletions src/common/pagination/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './paginated.interface'
export * from './pagination-prisma.interface'
16 changes: 16 additions & 0 deletions src/common/pagination/interfaces/paginated.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface Paginated<T> {
data: T[]
meta: {
itemsPerPage: number
totalItems: number
currentPage: number
totalPages: number
}
links: {
first: string
last: string
current: string
next: string | null
previous: string | null
}
}
22 changes: 22 additions & 0 deletions src/common/pagination/interfaces/pagination-prisma.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Prisma } from 'generated/prisma/client'

import type { ModelName } from 'generated/prisma/internal/prismaNamespace'

export interface PrismaDelegate<
T,
FindManyArgs = unknown,
CountArgs = unknown,
> {
findMany(args?: FindManyArgs): Promise<T[]>
count(args?: CountArgs): Promise<number>
}

export type PrismaFindManyArgs<M extends ModelName> =
Prisma.TypeMap['model'][M]['operations']['findMany']['args']
export type PrismaCountArgs<M extends ModelName> =
Prisma.TypeMap['model'][M]['operations']['count']['args']

export type PaginationOptions<M extends ModelName> = Omit<
PrismaFindManyArgs<M>,
'skip' | 'take'
>
8 changes: 8 additions & 0 deletions src/common/pagination/pagination.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { PaginationProvider } from './pagination.provider'

@Module({
providers: [PaginationProvider],
exports: [PaginationProvider],
})
export class PaginationModule {}
78 changes: 78 additions & 0 deletions src/common/pagination/pagination.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'
import { PaginationQueryDto } from './dto'

import type { Request } from 'express'
import type { ModelName } from 'generated/prisma/internal/prismaNamespace'
import type {
Paginated,
PaginationOptions,
PrismaCountArgs,
PrismaDelegate,
PrismaFindManyArgs,
} from './interfaces'

@Injectable()
export class PaginationProvider {
constructor(@Inject(REQUEST) private readonly request: Request) {}

/**
* @param delegate - Prisma model delegate (e.g., this.prisma.user)
* @param paginationQueryDto - Query parameters containing page and limit
* @param options - Prisma findMany options (where, select, orderBy, etc.)
*/
async paginateQuery<T, M extends ModelName>(
delegate: PrismaDelegate<T, PrismaFindManyArgs<M>, PrismaCountArgs<M>>,
paginationQueryDto: PaginationQueryDto,
options?: PaginationOptions<M>,
): Promise<Paginated<T>> {
const limit = Math.max(1, Number(paginationQueryDto.limit) || 10)
const page = Math.max(1, Number(paginationQueryDto.page) || 1)
const skip = (page - 1) * limit

// Execute both queries in parallel
const [data, totalItems] = await Promise.all([
delegate.findMany({
...options,
skip,
take: limit,
} as PrismaFindManyArgs<M>),
delegate.count({
where: options ? options.where : undefined,
} as PrismaCountArgs<M>),
])

const totalPages = Math.ceil(totalItems / limit)

if (page > totalPages && totalPages > 0) {
throw new BadRequestException(`Only ${totalPages} page(s) available`)
}

return {
data,
meta: {
itemsPerPage: limit,
totalItems,
currentPage: page,
totalPages,
},
links: {
first: this.buildLink(limit, 1),
last: this.buildLink(limit, totalPages || 1),
current: this.buildLink(limit, page),
next: page < totalPages ? this.buildLink(limit, page + 1) : null,
previous: page > 1 ? this.buildLink(limit, page - 1) : null,
},
}
}

private buildLink(limit: number, page: number): string {
const baseUrl = `${this.request.protocol}://${this.request.get('host')}`
const url = new URL(this.request.originalUrl, baseUrl)

url.searchParams.set('limit', limit.toString())
url.searchParams.set('page', page.toString())

return url.href
}
}
12 changes: 12 additions & 0 deletions src/user/dto/get-users.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsIn, IsOptional } from 'class-validator'
import { PaginationQueryDto } from 'src/common/pagination/dto'

export class GetUsersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsIn(['firstName', 'lastName'])
orderBy?: string

@IsOptional()
@IsIn(['asc', 'desc'])
order?: 'asc' | 'desc'
}
1 change: 1 addition & 0 deletions src/user/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './change-user-role.dto'
export * from './create-user.dto'
export * from './delete-user.dto'
export * from './get-users.dto'
export * from './reactivate-user.dto'
export * from './update-user.dto'
6 changes: 4 additions & 2 deletions src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common'
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
import { Role } from 'generated/prisma/enums'
import { Public, Roles, User } from 'src/auth/decorator'
import {
ChangeUserRoleDto,
DeleteUserDto,
GetUsersQueryDto,
ReactivateUserDto,
UpdateUserDto,
} from './dto'
Expand All @@ -29,8 +31,8 @@ export class UserController {

@Roles(Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN)
@Get()
getAllUsers() {
return this.userService.findAllUsers()
getAllUsers(@Query() getUsersQueryDto: GetUsersQueryDto) {
return this.userService.findAllUsers(getUsersQueryDto)
}

@Get('me')
Expand Down
3 changes: 2 additions & 1 deletion src/user/user.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'
import { PaginationModule } from 'src/common/pagination/pagination.module'
import { OtpModule } from 'src/otp/otp.module'
import { TokenModule } from 'src/token/token.module'
import { UserCleanupService } from './user-cleanup.service'
import { UserController } from './user.controller'
import { UserService } from './user.service'

@Module({
imports: [OtpModule, TokenModule],
imports: [OtpModule, TokenModule, PaginationModule],
controllers: [UserController],
providers: [UserService, UserCleanupService],
exports: [UserService],
Expand Down
75 changes: 69 additions & 6 deletions src/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PrismaService } from 'src/infra/prisma/prisma.service'
import { OtpService } from 'src/otp/otp.service'
import { TokenService } from 'src/token/token.service'
import { UserService } from './user.service'
import { PaginationProvider } from 'src/common/pagination/pagination.provider'

jest.mock('argon2')

Expand Down Expand Up @@ -40,13 +41,18 @@ describe('UserService', () => {
generateToken: jest.fn(),
}

const mockPaginationProvider = {
paginateQuery: jest.fn(),
}

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: OtpService, useValue: mockOtpService },
{ provide: TokenService, useValue: mockTokenService },
{ provide: PaginationProvider, useValue: mockPaginationProvider },
],
}).compile()

Expand All @@ -62,13 +68,70 @@ describe('UserService', () => {
})

describe('findAllUsers', () => {
it('should return all users', async () => {
mockPrisma.user.findMany.mockResolvedValue([{ id: 1 }])
const result = await service.findAllUsers()
expect(result).toEqual([{ id: 1 }])
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
omit: { password: true },
it('should return paginated users without sorting', async () => {
mockPaginationProvider.paginateQuery.mockResolvedValue({
data: [{ id: 1 }],
meta: {
totalItems: 1,
totalPages: 1,
currentPage: 1,
itemsPerPage: 10,
},
})
const result = await service.findAllUsers({})
expect(result).toEqual({
data: [{ id: 1 }],
meta: {
totalItems: 1,
totalPages: 1,
currentPage: 1,
itemsPerPage: 10,
},
})
expect(mockPaginationProvider.paginateQuery).toHaveBeenCalledWith(
mockPrisma.user,
{},
{
omit: { password: true },
orderBy: { undefined: undefined },
},
)
})

it('should return paginated users with sorting', async () => {
mockPaginationProvider.paginateQuery.mockResolvedValue({
data: [{ id: 1 }],
meta: {
totalItems: 1,
totalPages: 1,
currentPage: 1,
itemsPerPage: 10,
},
})
const query = {
page: 1,
limit: 10,
orderBy: 'firstName',
order: 'desc' as const,
}
const result = await service.findAllUsers(query)
expect(result).toEqual({
data: [{ id: 1 }],
meta: {
totalItems: 1,
totalPages: 1,
currentPage: 1,
itemsPerPage: 10,
},
})
expect(mockPaginationProvider.paginateQuery).toHaveBeenCalledWith(
mockPrisma.user,
query,
{
omit: { password: true },
orderBy: { firstName: 'desc' },
},
)
})
})

Expand Down
17 changes: 15 additions & 2 deletions src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
} from '@nestjs/common'
import * as argon from 'argon2'
import { Prisma } from 'generated/prisma/client'
import { PaginationProvider } from 'src/common/pagination/pagination.provider'
import { PrismaService } from 'src/infra/prisma/prisma.service'
import { OtpService } from 'src/otp/otp.service'
import { TokenService } from 'src/token/token.service'
import {
ChangeUserRoleDto,
CreateUserDto,
DeleteUserDto,
GetUsersQueryDto,
ReactivateUserDto,
UpdateUserDto,
} from './dto'
Expand All @@ -24,10 +26,21 @@ export class UserService {
private readonly prisma: PrismaService,
private readonly otpService: OtpService,
private readonly tokenService: TokenService,
private readonly paginationProvider: PaginationProvider,
) {}

async findAllUsers() {
return await this.prisma.user.findMany({ omit: { password: true } })
async findAllUsers(getUsersQueryDto: GetUsersQueryDto) {
const orderByField = getUsersQueryDto.orderBy
const orderDirection = getUsersQueryDto.order === 'desc' ? 'desc' : 'asc'

return await this.paginationProvider.paginateQuery(
this.prisma.user,
getUsersQueryDto,
{
omit: { password: true },
orderBy: { [orderByField!]: orderByField ? orderDirection : undefined },
},
)
}

async createUser(
Expand Down
Loading
Loading