Skip to content

Commit 5e9dd0a

Browse files
committed
feat: Add pagination to list endpoints (closes #63)
- Add standard limit/offset pagination to GET /bounties, /milestones, /maintenance-pools, and /users - Implement PaginationQueryDto with default page size of 50 and enforced maximum of 100 - Return paginated responses with metadata (page, limit, totalItems, totalPages, hasNextPage, hasPreviousPage) - Add comprehensive e2e tests verifying pagination behavior across all four endpoints - Ensure backward compatibility by making pagination parameters optional with sensible defaults - Order results by createdAt DESC for consistent pagination This resolves the unbounded query issue where list endpoints returned entire tables in a single response, preventing performance degradation as the platform scales.
1 parent 1c6e2e0 commit 5e9dd0a

11 files changed

Lines changed: 492 additions & 23 deletions

src/bounties/bounties.controller.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
2-
import { ApiTags } from '@nestjs/swagger';
2+
import { ApiTags, ApiQuery } from '@nestjs/swagger';
33
import { IsString } from 'class-validator';
44
import { BountiesService } from './bounties.service';
55
import { CreateBountyDto } from './dto/create-bounty.dto';
66
import { ClaimBountyDto } from './dto/claim-bounty.dto';
77
import { BountyStatus } from '../common/enums';
88
import { Idempotent } from '../common/idempotency/idempotent.decorator';
9+
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
10+
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
11+
import { Bounty } from '../common/entities';
912

1013
class FundBountyDto {
1114
@IsString()
@@ -23,8 +26,17 @@ export class BountiesController {
2326
}
2427

2528
@Get()
26-
list(@Query('status') status?: BountyStatus) {
27-
return this.bountiesService.list(status);
29+
@ApiQuery({ name: 'status', required: false, enum: BountyStatus })
30+
@ApiQuery({ name: 'page', required: false, type: Number })
31+
@ApiQuery({ name: 'limit', required: false, type: Number })
32+
async list(
33+
@Query('status') status?: BountyStatus,
34+
@Query() paginationQuery?: PaginationQueryDto,
35+
): Promise<PaginatedResponseDto<Bounty>> {
36+
const page = paginationQuery?.page || 1;
37+
const limit = paginationQuery?.limit || 50;
38+
const { data, total } = await this.bountiesService.list(status, page, limit);
39+
return new PaginatedResponseDto(data, page, limit, total);
2840
}
2941

3042
@Get(':id')

src/bounties/bounties.service.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,21 @@ export class BountiesService {
171171
return overdue.length;
172172
}
173173

174-
async list(status?: BountyStatus): Promise<Bounty[]> {
175-
return this.bountyRepo.find({ where: status ? { status } : {} });
174+
async list(
175+
status?: BountyStatus,
176+
page: number = 1,
177+
limit: number = 50,
178+
): Promise<{ data: Bounty[]; total: number }> {
179+
const skip = (page - 1) * limit;
180+
const where = status ? { status } : {};
181+
182+
const [data, total] = await this.bountyRepo.findAndCount({
183+
where,
184+
take: limit,
185+
skip,
186+
order: { createdAt: 'DESC' },
187+
});
188+
189+
return { data, total };
176190
}
177191
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
/**
4+
* Metadata for paginated responses, allowing clients to know if
5+
* more pages exist and how to request them.
6+
*/
7+
export class PaginationMetadata {
8+
@ApiProperty({ description: 'Current page number (1-indexed)' })
9+
page: number;
10+
11+
@ApiProperty({ description: 'Number of items per page' })
12+
limit: number;
13+
14+
@ApiProperty({ description: 'Total number of items across all pages' })
15+
totalItems: number;
16+
17+
@ApiProperty({ description: 'Total number of pages' })
18+
totalPages: number;
19+
20+
@ApiProperty({ description: 'Whether there is a next page' })
21+
hasNextPage: boolean;
22+
23+
@ApiProperty({ description: 'Whether there is a previous page' })
24+
hasPreviousPage: boolean;
25+
}
26+
27+
/**
28+
* Standard paginated response wrapper for list endpoints.
29+
*/
30+
export class PaginatedResponseDto<T> {
31+
@ApiProperty({ description: 'Array of items for the current page' })
32+
data: T[];
33+
34+
@ApiProperty({ description: 'Pagination metadata', type: PaginationMetadata })
35+
meta: PaginationMetadata;
36+
37+
constructor(
38+
data: T[],
39+
page: number,
40+
limit: number,
41+
totalItems: number,
42+
) {
43+
this.data = data;
44+
const totalPages = Math.ceil(totalItems / limit);
45+
this.meta = {
46+
page,
47+
limit,
48+
totalItems,
49+
totalPages,
50+
hasNextPage: page < totalPages,
51+
hasPreviousPage: page > 1,
52+
};
53+
}
54+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { IsInt, IsOptional, Min, Max } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { ApiPropertyOptional } from '@nestjs/swagger';
4+
5+
/**
6+
* Standard pagination query parameters for list endpoints.
7+
* Enforces a maximum page size to prevent unbounded responses.
8+
*/
9+
export class PaginationQueryDto {
10+
@ApiPropertyOptional({
11+
description: 'Page number (1-indexed)',
12+
default: 1,
13+
minimum: 1,
14+
})
15+
@IsOptional()
16+
@Type(() => Number)
17+
@IsInt()
18+
@Min(1)
19+
page?: number = 1;
20+
21+
@ApiPropertyOptional({
22+
description: 'Number of items per page',
23+
default: 50,
24+
minimum: 1,
25+
maximum: 100,
26+
})
27+
@IsOptional()
28+
@Type(() => Number)
29+
@IsInt()
30+
@Min(1)
31+
@Max(100)
32+
limit?: number = 50;
33+
}

src/maintenance-pool/maintenance-pool.controller.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
2-
import { ApiTags } from '@nestjs/swagger';
1+
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
2+
import { ApiTags, ApiQuery } from '@nestjs/swagger';
33
import { IsOptional, IsString, IsUUID } from 'class-validator';
44
import { MaintenancePoolService } from './maintenance-pool.service';
55
import { CreatePoolDto } from './dto/create-pool.dto';
66
import { IsMoneyAmount } from '../common/validators/money.validator';
77
import { Idempotent } from '../common/idempotency/idempotent.decorator';
8+
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
9+
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
10+
import { MaintenancePool } from '../common/entities';
811

912
class DepositDto {
1013
@IsMoneyAmount()
@@ -37,8 +40,15 @@ export class MaintenancePoolController {
3740
}
3841

3942
@Get()
40-
list() {
41-
return this.poolService.list();
43+
@ApiQuery({ name: 'page', required: false, type: Number })
44+
@ApiQuery({ name: 'limit', required: false, type: Number })
45+
async list(
46+
@Query() paginationQuery?: PaginationQueryDto,
47+
): Promise<PaginatedResponseDto<MaintenancePool>> {
48+
const page = paginationQuery?.page || 1;
49+
const limit = paginationQuery?.limit || 50;
50+
const { data, total } = await this.poolService.list(page, limit);
51+
return new PaginatedResponseDto(data, page, limit, total);
4252
}
4353

4454
@Get(':id')

src/maintenance-pool/maintenance-pool.service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,18 @@ export class MaintenancePoolService {
106106
return payment;
107107
}
108108

109-
async list(): Promise<MaintenancePool[]> {
110-
return this.poolRepo.find();
109+
async list(
110+
page: number = 1,
111+
limit: number = 50,
112+
): Promise<{ data: MaintenancePool[]; total: number }> {
113+
const skip = (page - 1) * limit;
114+
115+
const [data, total] = await this.poolRepo.findAndCount({
116+
take: limit,
117+
skip,
118+
order: { createdAt: 'DESC' },
119+
});
120+
121+
return { data, total };
111122
}
112123
}

src/milestones/milestones.controller.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
2-
import { ApiTags } from '@nestjs/swagger';
1+
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
2+
import { ApiTags, ApiQuery } from '@nestjs/swagger';
33
import { IsOptional, IsString, IsUUID } from 'class-validator';
44
import { MilestonesService } from './milestones.service';
55
import { CreateMilestoneDto } from './dto/create-milestone.dto';
66
import { Idempotent } from '../common/idempotency/idempotent.decorator';
7+
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
8+
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
9+
import { Milestone } from '../common/entities';
710

811
class FundMilestoneDto {
912
@IsString()
@@ -30,8 +33,15 @@ export class MilestonesController {
3033
}
3134

3235
@Get()
33-
list() {
34-
return this.milestonesService.list();
36+
@ApiQuery({ name: 'page', required: false, type: Number })
37+
@ApiQuery({ name: 'limit', required: false, type: Number })
38+
async list(
39+
@Query() paginationQuery?: PaginationQueryDto,
40+
): Promise<PaginatedResponseDto<Milestone>> {
41+
const page = paginationQuery?.page || 1;
42+
const limit = paginationQuery?.limit || 50;
43+
const { data, total } = await this.milestonesService.list(page, limit);
44+
return new PaginatedResponseDto(data, page, limit, total);
3545
}
3646

3747
@Get(':id')

src/milestones/milestones.service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,18 @@ export class MilestonesService {
128128
return payment;
129129
}
130130

131-
async list(): Promise<Milestone[]> {
132-
return this.milestoneRepo.find();
131+
async list(
132+
page: number = 1,
133+
limit: number = 50,
134+
): Promise<{ data: Milestone[]; total: number }> {
135+
const skip = (page - 1) * limit;
136+
137+
const [data, total] = await this.milestoneRepo.findAndCount({
138+
take: limit,
139+
skip,
140+
order: { createdAt: 'DESC' },
141+
});
142+
143+
return { data, total };
133144
}
134145
}

src/users/users.controller.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
2-
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
1+
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
2+
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
33
import { IsString } from 'class-validator';
44
import { UsersService } from './users.service';
55
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
6+
import { PaginationQueryDto } from '../common/dto/pagination-query.dto';
7+
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
8+
import { User } from '../common/entities';
69

710
class SetStellarAddressDto {
811
@IsString()
@@ -15,8 +18,15 @@ export class UsersController {
1518
constructor(private readonly usersService: UsersService) {}
1619

1720
@Get()
18-
list() {
19-
return this.usersService.list();
21+
@ApiQuery({ name: 'page', required: false, type: Number })
22+
@ApiQuery({ name: 'limit', required: false, type: Number })
23+
async list(
24+
@Query() paginationQuery?: PaginationQueryDto,
25+
): Promise<PaginatedResponseDto<User>> {
26+
const page = paginationQuery?.page || 1;
27+
const limit = paginationQuery?.limit || 50;
28+
const { data, total } = await this.usersService.list(page, limit);
29+
return new PaginatedResponseDto(data, page, limit, total);
2030
}
2131

2232
@Get(':id')

src/users/users.service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,18 @@ export class UsersService {
9898
return this.userRepo.save(user);
9999
}
100100

101-
async list(): Promise<User[]> {
102-
return this.userRepo.find();
101+
async list(
102+
page: number = 1,
103+
limit: number = 50,
104+
): Promise<{ data: User[]; total: number }> {
105+
const skip = (page - 1) * limit;
106+
107+
const [data, total] = await this.userRepo.findAndCount({
108+
take: limit,
109+
skip,
110+
order: { createdAt: 'DESC' },
111+
});
112+
113+
return { data, total };
103114
}
104115
}

0 commit comments

Comments
 (0)