diff --git a/src/common/pagination/dto/index.ts b/src/common/pagination/dto/index.ts new file mode 100644 index 0000000..b41da3e --- /dev/null +++ b/src/common/pagination/dto/index.ts @@ -0,0 +1 @@ +export * from './pagination-query.dto' diff --git a/src/common/pagination/dto/pagination-query.dto.ts b/src/common/pagination/dto/pagination-query.dto.ts new file mode 100644 index 0000000..8426ff9 --- /dev/null +++ b/src/common/pagination/dto/pagination-query.dto.ts @@ -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 +} diff --git a/src/common/pagination/interfaces/index.ts b/src/common/pagination/interfaces/index.ts new file mode 100644 index 0000000..171badb --- /dev/null +++ b/src/common/pagination/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './paginated.interface' +export * from './pagination-prisma.interface' diff --git a/src/common/pagination/interfaces/paginated.interface.ts b/src/common/pagination/interfaces/paginated.interface.ts new file mode 100644 index 0000000..c9ca4b8 --- /dev/null +++ b/src/common/pagination/interfaces/paginated.interface.ts @@ -0,0 +1,16 @@ +export interface Paginated { + data: T[] + meta: { + itemsPerPage: number + totalItems: number + currentPage: number + totalPages: number + } + links: { + first: string + last: string + current: string + next: string | null + previous: string | null + } +} diff --git a/src/common/pagination/interfaces/pagination-prisma.interface.ts b/src/common/pagination/interfaces/pagination-prisma.interface.ts new file mode 100644 index 0000000..e87afa7 --- /dev/null +++ b/src/common/pagination/interfaces/pagination-prisma.interface.ts @@ -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 + count(args?: CountArgs): Promise +} + +export type PrismaFindManyArgs = + Prisma.TypeMap['model'][M]['operations']['findMany']['args'] +export type PrismaCountArgs = + Prisma.TypeMap['model'][M]['operations']['count']['args'] + +export type PaginationOptions = Omit< + PrismaFindManyArgs, + 'skip' | 'take' +> diff --git a/src/common/pagination/pagination.module.ts b/src/common/pagination/pagination.module.ts new file mode 100644 index 0000000..bd3fc7f --- /dev/null +++ b/src/common/pagination/pagination.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { PaginationProvider } from './pagination.provider' + +@Module({ + providers: [PaginationProvider], + exports: [PaginationProvider], +}) +export class PaginationModule {} diff --git a/src/common/pagination/pagination.provider.ts b/src/common/pagination/pagination.provider.ts new file mode 100644 index 0000000..77da038 --- /dev/null +++ b/src/common/pagination/pagination.provider.ts @@ -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( + delegate: PrismaDelegate, PrismaCountArgs>, + paginationQueryDto: PaginationQueryDto, + options?: PaginationOptions, + ): Promise> { + 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), + delegate.count({ + where: options ? options.where : undefined, + } as PrismaCountArgs), + ]) + + 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 + } +} diff --git a/src/user/dto/get-users.dto.ts b/src/user/dto/get-users.dto.ts new file mode 100644 index 0000000..bc2c1d3 --- /dev/null +++ b/src/user/dto/get-users.dto.ts @@ -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' +} diff --git a/src/user/dto/index.ts b/src/user/dto/index.ts index 71c4481..1a46945 100644 --- a/src/user/dto/index.ts +++ b/src/user/dto/index.ts @@ -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' diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index c5162c4..52b91ca 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -9,6 +9,7 @@ import { ParseIntPipe, Patch, Post, + Query, } from '@nestjs/common' import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' import { Role } from 'generated/prisma/enums' @@ -16,6 +17,7 @@ import { Public, Roles, User } from 'src/auth/decorator' import { ChangeUserRoleDto, DeleteUserDto, + GetUsersQueryDto, ReactivateUserDto, UpdateUserDto, } from './dto' @@ -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') diff --git a/src/user/user.module.ts b/src/user/user.module.ts index a73dbf7..43159a4 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,4 +1,5 @@ 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' @@ -6,7 +7,7 @@ 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], diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 53f6172..b30c064 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -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') @@ -40,6 +41,10 @@ describe('UserService', () => { generateToken: jest.fn(), } + const mockPaginationProvider = { + paginateQuery: jest.fn(), + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -47,6 +52,7 @@ describe('UserService', () => { { provide: PrismaService, useValue: mockPrisma }, { provide: OtpService, useValue: mockOtpService }, { provide: TokenService, useValue: mockTokenService }, + { provide: PaginationProvider, useValue: mockPaginationProvider }, ], }).compile() @@ -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' }, + }, + ) }) }) diff --git a/src/user/user.service.ts b/src/user/user.service.ts index f0d72ce..d90b1c3 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -7,6 +7,7 @@ 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' @@ -14,6 +15,7 @@ import { ChangeUserRoleDto, CreateUserDto, DeleteUserDto, + GetUsersQueryDto, ReactivateUserDto, UpdateUserDto, } from './dto' @@ -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( diff --git a/test/user.e2e-spec.ts b/test/user.e2e-spec.ts index 1434a09..f8c5c12 100644 --- a/test/user.e2e-spec.ts +++ b/test/user.e2e-spec.ts @@ -5,6 +5,7 @@ import { AppModule } from 'src/app.module' import { PrismaService } from 'src/infra/prisma/prisma.service' import request from 'supertest' import { App } from 'supertest/types' +import { Paginated } from 'src/common/pagination/interfaces' describe('UserController (e2e)', () => { let app: INestApplication @@ -300,15 +301,37 @@ describe('UserController (e2e)', () => { await getTokens('user1@example.com') await getTokens('user2@example.com') - const res: { body: { length: string } } = await request( + const res: { body: Paginated } = await request( app.getHttpServer(), ) .get('/users') .set('Authorization', `Bearer ${accessToken}`) .expect(200) - expect(Array.isArray(res.body)).toBe(true) - expect(res.body.length).toBeGreaterThanOrEqual(3) + expect(Array.isArray(res.body.data)).toBe(true) + expect(res.body.data.length).toBeGreaterThanOrEqual(3) + expect(res.body.meta).toBeDefined() + }) + + it('should return paginated and sorted users for SUPER_ADMIN', async () => { + const { accessToken } = await getTokens( + 'admin-paginated@example.com', + Role.SUPER_ADMIN, + ) + await getTokens('userA@example.com') + await getTokens('userB@example.com') + + const res: { body: Paginated } = await request( + app.getHttpServer(), + ) + .get('/users?page=1&limit=2&orderBy=email&order=desc') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + + expect(Array.isArray(res.body.data)).toBe(true) + expect(res.body.data.length).toBeLessThanOrEqual(2) + expect(res.body.meta.currentPage).toBe(1) + expect(res.body.meta.itemsPerPage).toBe(2) }) it('should return 403 for regular USER', async () => {