From e83d292a57190dc49435f6b24a87a3427b7bce7b Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Wed, 6 May 2026 15:46:35 +0530 Subject: [PATCH 1/4] feat: add pagination provider --- src/app.module.ts | 2 + src/common/pagination/dto/index.ts | 1 + .../pagination/dto/pagination-query.dto.ts | 11 + src/common/pagination/interfaces/index.ts | 1 + .../interfaces/paginated.interface.ts | 16 + src/common/pagination/pagination.module.ts | 8 + src/common/pagination/pagination.provider.ts | 359 ++++++++++++++++++ src/user/user.service.ts | 12 +- 8 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/common/pagination/dto/index.ts create mode 100644 src/common/pagination/dto/pagination-query.dto.ts create mode 100644 src/common/pagination/interfaces/index.ts create mode 100644 src/common/pagination/interfaces/paginated.interface.ts create mode 100644 src/common/pagination/pagination.module.ts create mode 100644 src/common/pagination/pagination.provider.ts diff --git a/src/app.module.ts b/src/app.module.ts index 3c48508..13e76b2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { AuthInfrastructureModule } from './auth/auth-infrastructure.module' import { AuthModule } from './auth/auth.module' import { AllExceptionsFilter } from './common/filter' import { RequestContextMiddleware } from './common/middleware' +import { PaginationModule } from './common/pagination/pagination.module' import envValidation from './config/env.validation' import { AlsModule } from './infra/als/als.module' import { PrismaModule } from './infra/prisma/prisma.module' @@ -47,6 +48,7 @@ import { UserModule } from './user/user.module' }), ScheduleModule.forRoot(), AlsModule, + PaginationModule, ], controllers: [AppController], providers: [ 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..2f8335e --- /dev/null +++ b/src/common/pagination/dto/pagination-query.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsPositive } from 'class-validator' + +export class PaginationQueryDto { + @IsOptional() + @IsPositive() + limit?: number = 10 + + @IsOptional() + @IsPositive() + page?: number = 1 +} diff --git a/src/common/pagination/interfaces/index.ts b/src/common/pagination/interfaces/index.ts new file mode 100644 index 0000000..adcd9ec --- /dev/null +++ b/src/common/pagination/interfaces/index.ts @@ -0,0 +1 @@ +export * from './paginated.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/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..161e8d5 --- /dev/null +++ b/src/common/pagination/pagination.provider.ts @@ -0,0 +1,359 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { REQUEST } from '@nestjs/core' +import type { Request } from 'express' + +import { PaginationQueryDto } from './dto' +import { Paginated } from './interfaces' + +export interface PrismaDelegate { + findMany(args?: FindManyArgs): Promise + count(args?: CountArgs): Promise +} + +@Injectable() +export class PaginationProvider { + constructor(@Inject(REQUEST) private readonly request: Request) {} + + /** + * @param delegate - The Prisma model delegate (e.g., this.prisma.user) + * @param paginationQueryDto - Page and Limit + * @param options - Prisma FindMany arguments (where, select, omit, etc.) + */ + async paginateQuery< + T, + FindManyArgs extends { skip?: number; take?: number }, + CountArgs extends { where?: unknown }, + >( + delegate: PrismaDelegate, + paginationQueryDto: PaginationQueryDto, + options?: Omit, + ): Promise> { + const limit = Number(paginationQueryDto.limit) || 10 + const page = Number(paginationQueryDto.page) || 1 + const skip = (page - 1) * limit + + const where = ( + options && 'where' in options ? options.where : undefined + ) as CountArgs['where'] + + // Execute queries in parallel + const [data, totalItems] = await Promise.all([ + delegate.findMany({ + ...options, + skip, + take: limit, + } as unknown as FindManyArgs), + delegate.count({ + where, + } as unknown as CountArgs), + ]) + + 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.getLink(limit, 1), + last: this.getLink(limit, totalPages || 1), + current: this.getLink(limit, page), + next: page < totalPages ? this.getLink(limit, page + 1) : null, + previous: page > 1 ? this.getLink(limit, page - 1) : null, + }, + } + } + + private getLink(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 + } +} + +// import { BadRequestException, Inject, Injectable } from '@nestjs/common' +// import { REQUEST } from '@nestjs/core' +// import type { Request } from 'express' +// import { Prisma } from 'generated/prisma/client' +// import { ModelName } from 'generated/prisma/internal/prismaNamespace' +// import { PrismaService } from 'src/infra/prisma/prisma.service' +// import { PaginationQueryDto } from './dto' +// import { Paginated } from './interfaces' + +// // Define a union type of all model names available in Prisma +// export type ModelNames = +// (typeof Prisma.ModelName)[keyof typeof Prisma.ModelName] + +// // Define a type for Prisma operations specific to a given model +// type PrismaOperations = +// Prisma.TypeMap['model'][ModelName]['operations'] + +// // Define a type for Prisma findMany arguments specific to a given model +// type PrismaFindManyArgs = +// PrismaOperations['findMany']['args'] + +// // Define a type for pagination options, including model name, query filters, and pagination parameters +// type PaginationOptions = { +// // modelName: ModelName // Name of the model to paginate +// where?: PrismaFindManyArgs['where'] // Filtering conditions for the query +// omit?: PrismaFindManyArgs['omit'] +// select?: PrismaFindManyArgs['select'] +// orderBy?: PrismaFindManyArgs['orderBy'] // Sorting criteria for the query +// // page?: string // Page number for pagination +// // pageSize?: string // Number of items per page for pagination +// } + +// @Injectable() +// export class PaginationProvider { +// constructor( +// private readonly prisma: PrismaService, +// @Inject(REQUEST) private readonly request: Request, +// ) {} + +// async paginateQuery( +// modelName: ModelName, +// paginationQueryDto: PaginationQueryDto, +// options: PaginationOptions, +// ): Promise> { +// // Use 'any' or a dynamic return type here +// const limit = paginationQueryDto.limit ?? 10 +// const page = paginationQueryDto.page ?? 1 +// const skip = (page - 1) * limit + +// // 1. FIX: Access the model delegate dynamically from PrismaService +// // This allows you to call .findMany and .count on the correct table +// const db = this.prisma[modelName as string] + +// // 2. FIX: Run count and findMany on the dynamic 'db' constant +// const [data, totalItems] = await Promise.all([ +// db.findMany({ +// ...options, +// skip, +// take: limit, +// }), +// db.count({ +// where: options.where, // Only where is valid for count +// }), +// ]) + +// const totalPages = Math.ceil(totalItems / limit) + +// if (page > totalPages && totalPages > 0) { +// throw new BadRequestException(`Only ${totalPages} page(s) available`) +// } + +// const nextPage = page === totalPages ? null : page + 1 +// const prevPage = page === 1 ? null : page - 1 + +// return { +// data, +// meta: { +// itemsPerPage: limit, +// totalItems, +// currentPage: page, +// totalPages, +// }, +// links: { +// first: this.getLink(limit, 1), +// last: this.getLink(limit, totalPages || 1), +// current: this.getLink(limit, page), +// next: nextPage ? this.getLink(limit, nextPage) : null, +// previous: prevPage ? this.getLink(limit, prevPage) : null, +// }, +// } +// } + +// private getLink(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 +// } +// } + +// import { BadRequestException, Inject, Injectable } from '@nestjs/common' +// import { REQUEST } from '@nestjs/core' +// import type { Request } from 'express' +// import { Prisma } from 'generated/prisma/browser' +// import { PrismaService } from 'src/infra/prisma/prisma.service' +// import { PaginationQueryDto } from './dto' +// import { Paginated } from './interfaces' + +// // Define a union type of all model names available in Prisma +// export type ModelNames = +// (typeof Prisma.ModelName)[keyof typeof Prisma.ModelName] + +// // Define a type for Prisma operations specific to a given model +// type PrismaOperations = +// Prisma.TypeMap['model'][ModelName]['operations'] + +// // Define a type for Prisma findMany arguments specific to a given model +// type PrismaFindManyArgs = +// PrismaOperations['findMany']['args'] + +// // Define a type for pagination options, including model name, query filters, and pagination parameters +// type PaginationOptions = { +// // modelName: ModelName // Name of the model to paginate +// where?: PrismaFindManyArgs['where'] // Filtering conditions for the query +// omit?: PrismaFindManyArgs['omit'] +// select?: PrismaFindManyArgs['select'] +// orderBy?: PrismaFindManyArgs['orderBy'] // Sorting criteria for the query +// // page?: string // Page number for pagination +// // pageSize?: string // Number of items per page for pagination +// } + +// @Injectable() +// export class PaginationProvider { +// constructor( +// private readonly prisma: PrismaService, +// @Inject(REQUEST) private readonly request: Request, +// ) {} + +// async paginateQuery( +// model: ModelName, +// // { +// // findMany: (args) => Prisma.PrismaPromise +// // count: (args) => Prisma.PrismaPromise +// // }, +// paginationQueryDto: PaginationQueryDto, +// // options: { +// // where?: any +// // orderBy?: any +// // include?: any +// // select?: any +// // omit?: any +// // } = {}, +// options: PaginationOptions, +// ): Promise> { +// const limit = paginationQueryDto.limit ?? 10 +// const page = paginationQueryDto.page ?? 1 +// const skip = (page - 1) * limit + +// await this.prisma.user.findMany({ +// ...options, +// skip, +// take: limit, +// }) +// // Run in parallel (better than sequential like your TypeORM version) +// const [data, totalItems] = await Promise.all([ +// this.prisma[model as string].findMany({ +// ...options, +// skip, +// take: limit, +// }), +// model.count({ +// ...options, +// }), +// ]) + +// const totalPages = Math.ceil(totalItems / limit) + +// if (page > totalPages && totalPages > 0) { +// throw new BadRequestException(`Only ${totalPages} page(s) available`) +// } + +// const nextPage = page === totalPages ? null : page + 1 +// const prevPage = page === 1 ? null : page - 1 + +// return { +// data, +// meta: { +// itemsPerPage: limit, +// totalItems, +// currentPage: page, +// totalPages, +// }, +// links: { +// first: this.getLink(limit, 1), +// last: this.getLink(limit, totalPages || 1), +// current: this.getLink(limit, page), +// next: nextPage ? this.getLink(limit, nextPage) : null, +// previous: prevPage ? this.getLink(limit, prevPage) : null, +// }, +// } +// } + +// private getLink(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 +// } +// } + +// import { BadRequestException, Inject, Injectable } from '@nestjs/common' +// import { REQUEST } from '@nestjs/core' +// import { FindManyOptions, ObjectLiteral, Repository } from 'typeorm' +// import { PaginationQueryDto } from './dto' +// import { Paginated } from './interfaces' + +// import type { Request } from 'express' + +// @Injectable() +// export class PaginationProvider { +// constructor(@Inject(REQUEST) private readonly request: Request) {} + +// async paginateQuery( +// repository: Repository, +// paginationQueryDto: PaginationQueryDto, +// options: FindManyOptions = {}, +// ): Promise> { +// const limit = paginationQueryDto.limit ?? 10 +// const page = paginationQueryDto.page ?? 1 + +// const data = await repository.find({ +// ...options, +// skip: (page - 1) * limit, +// take: limit, +// }) + +// const totalItems = await repository.count({ ...options }) +// const totalPages = Math.ceil(totalItems / limit) +// if (page > totalPages) +// throw new BadRequestException(`Only ${totalPages} page(s) available`) + +// const nextPage = page === totalPages ? null : page + 1 +// const prevPage = page === 1 ? null : page - 1 + +// return { +// data, +// meta: { +// itemsPerPage: limit, +// totalItems: totalItems, +// currentPage: page, +// totalPages: totalPages, +// }, +// links: { +// first: this.getLink(limit, 1), +// last: this.getLink(limit, totalPages), +// current: this.getLink(limit, page), +// next: nextPage ? this.getLink(limit, nextPage) : null, +// previous: prevPage ? this.getLink(limit, prevPage) : null, +// }, +// } +// } + +// private getLink(limit: number, page: number): string { +// const baseUrl = this.request.protocol + '://' + this.request.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/user.service.ts b/src/user/user.service.ts index f0d72ce..6596968 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' @@ -24,10 +25,19 @@ export class UserService { private readonly prisma: PrismaService, private readonly otpService: OtpService, private readonly tokenService: TokenService, + private readonly paginate: PaginationProvider, ) {} async findAllUsers() { - return await this.prisma.user.findMany({ omit: { password: true } }) + // return await this.prisma.user.findMany({ omit: { password: true } }) + return await this.paginate.paginateQuery( + this.prisma.user, + { + limit: 10, + page: 1, + }, + { omit: { password: true } }, + ) } async createUser( From f07e6f8c874ad29c4a2a758f6d9ee758c8b2c134 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Wed, 6 May 2026 17:40:39 +0530 Subject: [PATCH 2/4] feat: modify pagination provider --- src/app.module.ts | 2 - .../pagination/dto/pagination-query.dto.ts | 3 + src/common/pagination/interfaces/index.ts | 1 + .../interfaces/pagination-prisma.interface.ts | 22 ++ src/common/pagination/pagination.provider.ts | 341 ++---------------- src/user/user.controller.ts | 6 +- src/user/user.module.ts | 3 +- src/user/user.service.ts | 13 +- 8 files changed, 67 insertions(+), 324 deletions(-) create mode 100644 src/common/pagination/interfaces/pagination-prisma.interface.ts diff --git a/src/app.module.ts b/src/app.module.ts index 13e76b2..3c48508 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,6 @@ import { AuthInfrastructureModule } from './auth/auth-infrastructure.module' import { AuthModule } from './auth/auth.module' import { AllExceptionsFilter } from './common/filter' import { RequestContextMiddleware } from './common/middleware' -import { PaginationModule } from './common/pagination/pagination.module' import envValidation from './config/env.validation' import { AlsModule } from './infra/als/als.module' import { PrismaModule } from './infra/prisma/prisma.module' @@ -48,7 +47,6 @@ import { UserModule } from './user/user.module' }), ScheduleModule.forRoot(), AlsModule, - PaginationModule, ], controllers: [AppController], providers: [ diff --git a/src/common/pagination/dto/pagination-query.dto.ts b/src/common/pagination/dto/pagination-query.dto.ts index 2f8335e..a55315a 100644 --- a/src/common/pagination/dto/pagination-query.dto.ts +++ b/src/common/pagination/dto/pagination-query.dto.ts @@ -1,10 +1,13 @@ +import { Type } from 'class-transformer' import { IsOptional, IsPositive } from 'class-validator' export class PaginationQueryDto { + @Type(() => Number) @IsOptional() @IsPositive() limit?: number = 10 + @Type(() => Number) @IsOptional() @IsPositive() page?: number = 1 diff --git a/src/common/pagination/interfaces/index.ts b/src/common/pagination/interfaces/index.ts index adcd9ec..171badb 100644 --- a/src/common/pagination/interfaces/index.ts +++ b/src/common/pagination/interfaces/index.ts @@ -1 +1,2 @@ export * from './paginated.interface' +export * from './pagination-prisma.interface' 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.provider.ts b/src/common/pagination/pagination.provider.ts index 161e8d5..77da038 100644 --- a/src/common/pagination/pagination.provider.ts +++ b/src/common/pagination/pagination.provider.ts @@ -1,51 +1,45 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { REQUEST } from '@nestjs/core' -import type { Request } from 'express' - import { PaginationQueryDto } from './dto' -import { Paginated } from './interfaces' -export interface PrismaDelegate { - findMany(args?: FindManyArgs): Promise - count(args?: CountArgs): Promise -} +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 - The Prisma model delegate (e.g., this.prisma.user) - * @param paginationQueryDto - Page and Limit - * @param options - Prisma FindMany arguments (where, select, omit, etc.) + * @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, - FindManyArgs extends { skip?: number; take?: number }, - CountArgs extends { where?: unknown }, - >( - delegate: PrismaDelegate, + async paginateQuery( + delegate: PrismaDelegate, PrismaCountArgs>, paginationQueryDto: PaginationQueryDto, - options?: Omit, + options?: PaginationOptions, ): Promise> { - const limit = Number(paginationQueryDto.limit) || 10 - const page = Number(paginationQueryDto.page) || 1 + const limit = Math.max(1, Number(paginationQueryDto.limit) || 10) + const page = Math.max(1, Number(paginationQueryDto.page) || 1) const skip = (page - 1) * limit - const where = ( - options && 'where' in options ? options.where : undefined - ) as CountArgs['where'] - - // Execute queries in parallel + // Execute both queries in parallel const [data, totalItems] = await Promise.all([ delegate.findMany({ ...options, skip, take: limit, - } as unknown as FindManyArgs), + } as PrismaFindManyArgs), delegate.count({ - where, - } as unknown as CountArgs), + where: options ? options.where : undefined, + } as PrismaCountArgs), ]) const totalPages = Math.ceil(totalItems / limit) @@ -63,297 +57,22 @@ export class PaginationProvider { totalPages, }, links: { - first: this.getLink(limit, 1), - last: this.getLink(limit, totalPages || 1), - current: this.getLink(limit, page), - next: page < totalPages ? this.getLink(limit, page + 1) : null, - previous: page > 1 ? this.getLink(limit, page - 1) : null, + 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 getLink(limit: number, page: number): string { - const baseUrl = this.request.protocol + '://' + this.request.get('host') + 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 } } - -// import { BadRequestException, Inject, Injectable } from '@nestjs/common' -// import { REQUEST } from '@nestjs/core' -// import type { Request } from 'express' -// import { Prisma } from 'generated/prisma/client' -// import { ModelName } from 'generated/prisma/internal/prismaNamespace' -// import { PrismaService } from 'src/infra/prisma/prisma.service' -// import { PaginationQueryDto } from './dto' -// import { Paginated } from './interfaces' - -// // Define a union type of all model names available in Prisma -// export type ModelNames = -// (typeof Prisma.ModelName)[keyof typeof Prisma.ModelName] - -// // Define a type for Prisma operations specific to a given model -// type PrismaOperations = -// Prisma.TypeMap['model'][ModelName]['operations'] - -// // Define a type for Prisma findMany arguments specific to a given model -// type PrismaFindManyArgs = -// PrismaOperations['findMany']['args'] - -// // Define a type for pagination options, including model name, query filters, and pagination parameters -// type PaginationOptions = { -// // modelName: ModelName // Name of the model to paginate -// where?: PrismaFindManyArgs['where'] // Filtering conditions for the query -// omit?: PrismaFindManyArgs['omit'] -// select?: PrismaFindManyArgs['select'] -// orderBy?: PrismaFindManyArgs['orderBy'] // Sorting criteria for the query -// // page?: string // Page number for pagination -// // pageSize?: string // Number of items per page for pagination -// } - -// @Injectable() -// export class PaginationProvider { -// constructor( -// private readonly prisma: PrismaService, -// @Inject(REQUEST) private readonly request: Request, -// ) {} - -// async paginateQuery( -// modelName: ModelName, -// paginationQueryDto: PaginationQueryDto, -// options: PaginationOptions, -// ): Promise> { -// // Use 'any' or a dynamic return type here -// const limit = paginationQueryDto.limit ?? 10 -// const page = paginationQueryDto.page ?? 1 -// const skip = (page - 1) * limit - -// // 1. FIX: Access the model delegate dynamically from PrismaService -// // This allows you to call .findMany and .count on the correct table -// const db = this.prisma[modelName as string] - -// // 2. FIX: Run count and findMany on the dynamic 'db' constant -// const [data, totalItems] = await Promise.all([ -// db.findMany({ -// ...options, -// skip, -// take: limit, -// }), -// db.count({ -// where: options.where, // Only where is valid for count -// }), -// ]) - -// const totalPages = Math.ceil(totalItems / limit) - -// if (page > totalPages && totalPages > 0) { -// throw new BadRequestException(`Only ${totalPages} page(s) available`) -// } - -// const nextPage = page === totalPages ? null : page + 1 -// const prevPage = page === 1 ? null : page - 1 - -// return { -// data, -// meta: { -// itemsPerPage: limit, -// totalItems, -// currentPage: page, -// totalPages, -// }, -// links: { -// first: this.getLink(limit, 1), -// last: this.getLink(limit, totalPages || 1), -// current: this.getLink(limit, page), -// next: nextPage ? this.getLink(limit, nextPage) : null, -// previous: prevPage ? this.getLink(limit, prevPage) : null, -// }, -// } -// } - -// private getLink(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 -// } -// } - -// import { BadRequestException, Inject, Injectable } from '@nestjs/common' -// import { REQUEST } from '@nestjs/core' -// import type { Request } from 'express' -// import { Prisma } from 'generated/prisma/browser' -// import { PrismaService } from 'src/infra/prisma/prisma.service' -// import { PaginationQueryDto } from './dto' -// import { Paginated } from './interfaces' - -// // Define a union type of all model names available in Prisma -// export type ModelNames = -// (typeof Prisma.ModelName)[keyof typeof Prisma.ModelName] - -// // Define a type for Prisma operations specific to a given model -// type PrismaOperations = -// Prisma.TypeMap['model'][ModelName]['operations'] - -// // Define a type for Prisma findMany arguments specific to a given model -// type PrismaFindManyArgs = -// PrismaOperations['findMany']['args'] - -// // Define a type for pagination options, including model name, query filters, and pagination parameters -// type PaginationOptions = { -// // modelName: ModelName // Name of the model to paginate -// where?: PrismaFindManyArgs['where'] // Filtering conditions for the query -// omit?: PrismaFindManyArgs['omit'] -// select?: PrismaFindManyArgs['select'] -// orderBy?: PrismaFindManyArgs['orderBy'] // Sorting criteria for the query -// // page?: string // Page number for pagination -// // pageSize?: string // Number of items per page for pagination -// } - -// @Injectable() -// export class PaginationProvider { -// constructor( -// private readonly prisma: PrismaService, -// @Inject(REQUEST) private readonly request: Request, -// ) {} - -// async paginateQuery( -// model: ModelName, -// // { -// // findMany: (args) => Prisma.PrismaPromise -// // count: (args) => Prisma.PrismaPromise -// // }, -// paginationQueryDto: PaginationQueryDto, -// // options: { -// // where?: any -// // orderBy?: any -// // include?: any -// // select?: any -// // omit?: any -// // } = {}, -// options: PaginationOptions, -// ): Promise> { -// const limit = paginationQueryDto.limit ?? 10 -// const page = paginationQueryDto.page ?? 1 -// const skip = (page - 1) * limit - -// await this.prisma.user.findMany({ -// ...options, -// skip, -// take: limit, -// }) -// // Run in parallel (better than sequential like your TypeORM version) -// const [data, totalItems] = await Promise.all([ -// this.prisma[model as string].findMany({ -// ...options, -// skip, -// take: limit, -// }), -// model.count({ -// ...options, -// }), -// ]) - -// const totalPages = Math.ceil(totalItems / limit) - -// if (page > totalPages && totalPages > 0) { -// throw new BadRequestException(`Only ${totalPages} page(s) available`) -// } - -// const nextPage = page === totalPages ? null : page + 1 -// const prevPage = page === 1 ? null : page - 1 - -// return { -// data, -// meta: { -// itemsPerPage: limit, -// totalItems, -// currentPage: page, -// totalPages, -// }, -// links: { -// first: this.getLink(limit, 1), -// last: this.getLink(limit, totalPages || 1), -// current: this.getLink(limit, page), -// next: nextPage ? this.getLink(limit, nextPage) : null, -// previous: prevPage ? this.getLink(limit, prevPage) : null, -// }, -// } -// } - -// private getLink(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 -// } -// } - -// import { BadRequestException, Inject, Injectable } from '@nestjs/common' -// import { REQUEST } from '@nestjs/core' -// import { FindManyOptions, ObjectLiteral, Repository } from 'typeorm' -// import { PaginationQueryDto } from './dto' -// import { Paginated } from './interfaces' - -// import type { Request } from 'express' - -// @Injectable() -// export class PaginationProvider { -// constructor(@Inject(REQUEST) private readonly request: Request) {} - -// async paginateQuery( -// repository: Repository, -// paginationQueryDto: PaginationQueryDto, -// options: FindManyOptions = {}, -// ): Promise> { -// const limit = paginationQueryDto.limit ?? 10 -// const page = paginationQueryDto.page ?? 1 - -// const data = await repository.find({ -// ...options, -// skip: (page - 1) * limit, -// take: limit, -// }) - -// const totalItems = await repository.count({ ...options }) -// const totalPages = Math.ceil(totalItems / limit) -// if (page > totalPages) -// throw new BadRequestException(`Only ${totalPages} page(s) available`) - -// const nextPage = page === totalPages ? null : page + 1 -// const prevPage = page === 1 ? null : page - 1 - -// return { -// data, -// meta: { -// itemsPerPage: limit, -// totalItems: totalItems, -// currentPage: page, -// totalPages: totalPages, -// }, -// links: { -// first: this.getLink(limit, 1), -// last: this.getLink(limit, totalPages), -// current: this.getLink(limit, page), -// next: nextPage ? this.getLink(limit, nextPage) : null, -// previous: prevPage ? this.getLink(limit, prevPage) : null, -// }, -// } -// } - -// private getLink(limit: number, page: number): string { -// const baseUrl = this.request.protocol + '://' + this.request.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/user.controller.ts b/src/user/user.controller.ts index c5162c4..768172c 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -9,10 +9,12 @@ 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 { PaginationQueryDto } from 'src/common/pagination/dto' import { ChangeUserRoleDto, DeleteUserDto, @@ -29,8 +31,8 @@ export class UserController { @Roles(Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN) @Get() - getAllUsers() { - return this.userService.findAllUsers() + getAllUsers(@Query() paginationQueryDto: PaginationQueryDto) { + return this.userService.findAllUsers(paginationQueryDto) } @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.ts b/src/user/user.service.ts index 6596968..bdd101a 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 { PaginationQueryDto } from 'src/common/pagination/dto' import { PaginationProvider } from 'src/common/pagination/pagination.provider' import { PrismaService } from 'src/infra/prisma/prisma.service' import { OtpService } from 'src/otp/otp.service' @@ -25,17 +26,13 @@ export class UserService { private readonly prisma: PrismaService, private readonly otpService: OtpService, private readonly tokenService: TokenService, - private readonly paginate: PaginationProvider, + private readonly paginationProvider: PaginationProvider, ) {} - async findAllUsers() { - // return await this.prisma.user.findMany({ omit: { password: true } }) - return await this.paginate.paginateQuery( + async findAllUsers(paginationQueryDto: PaginationQueryDto) { + return await this.paginationProvider.paginateQuery( this.prisma.user, - { - limit: 10, - page: 1, - }, + paginationQueryDto, { omit: { password: true } }, ) } From ee7753f495d6757d22ce44753f8596bd6ea0bf88 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Wed, 6 May 2026 21:43:58 +0530 Subject: [PATCH 3/4] feat: add orderBy filter in getAllUsers --- src/common/pagination/dto/pagination-query.dto.ts | 4 ++-- src/user/dto/get-users.dto.ts | 12 ++++++++++++ src/user/dto/index.ts | 1 + src/user/user.controller.ts | 6 +++--- src/user/user.service.ts | 14 ++++++++++---- 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/user/dto/get-users.dto.ts diff --git a/src/common/pagination/dto/pagination-query.dto.ts b/src/common/pagination/dto/pagination-query.dto.ts index a55315a..8426ff9 100644 --- a/src/common/pagination/dto/pagination-query.dto.ts +++ b/src/common/pagination/dto/pagination-query.dto.ts @@ -5,10 +5,10 @@ export class PaginationQueryDto { @Type(() => Number) @IsOptional() @IsPositive() - limit?: number = 10 + limit?: number @Type(() => Number) @IsOptional() @IsPositive() - page?: number = 1 + page?: number } 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 768172c..52b91ca 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -14,10 +14,10 @@ import { import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' import { Role } from 'generated/prisma/enums' import { Public, Roles, User } from 'src/auth/decorator' -import { PaginationQueryDto } from 'src/common/pagination/dto' import { ChangeUserRoleDto, DeleteUserDto, + GetUsersQueryDto, ReactivateUserDto, UpdateUserDto, } from './dto' @@ -31,8 +31,8 @@ export class UserController { @Roles(Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN) @Get() - getAllUsers(@Query() paginationQueryDto: PaginationQueryDto) { - return this.userService.findAllUsers(paginationQueryDto) + getAllUsers(@Query() getUsersQueryDto: GetUsersQueryDto) { + return this.userService.findAllUsers(getUsersQueryDto) } @Get('me') diff --git a/src/user/user.service.ts b/src/user/user.service.ts index bdd101a..d90b1c3 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -7,7 +7,6 @@ import { } from '@nestjs/common' import * as argon from 'argon2' import { Prisma } from 'generated/prisma/client' -import { PaginationQueryDto } from 'src/common/pagination/dto' import { PaginationProvider } from 'src/common/pagination/pagination.provider' import { PrismaService } from 'src/infra/prisma/prisma.service' import { OtpService } from 'src/otp/otp.service' @@ -16,6 +15,7 @@ import { ChangeUserRoleDto, CreateUserDto, DeleteUserDto, + GetUsersQueryDto, ReactivateUserDto, UpdateUserDto, } from './dto' @@ -29,11 +29,17 @@ export class UserService { private readonly paginationProvider: PaginationProvider, ) {} - async findAllUsers(paginationQueryDto: PaginationQueryDto) { + async findAllUsers(getUsersQueryDto: GetUsersQueryDto) { + const orderByField = getUsersQueryDto.orderBy + const orderDirection = getUsersQueryDto.order === 'desc' ? 'desc' : 'asc' + return await this.paginationProvider.paginateQuery( this.prisma.user, - paginationQueryDto, - { omit: { password: true } }, + getUsersQueryDto, + { + omit: { password: true }, + orderBy: { [orderByField!]: orderByField ? orderDirection : undefined }, + }, ) } From 5bbf4ee676d07d0daa17792ac16e60e315fc2992 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Wed, 6 May 2026 22:29:30 +0530 Subject: [PATCH 4/4] test: add tests for paginated getAllUsers query --- src/user/user.service.spec.ts | 75 ++++++++++++++++++++++++++++++++--- test/user.e2e-spec.ts | 29 ++++++++++++-- 2 files changed, 95 insertions(+), 9 deletions(-) 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/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 () => {