Skip to content

Commit 8091e71

Browse files
authored
Merge pull request #164 from caramel222/feat/cursor-pagination
Pagination standards: cursors, ordering, and limit enforcement
2 parents 7770546 + fb24384 commit 8091e71

7 files changed

Lines changed: 940 additions & 457 deletions

File tree

backend/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ model Claim {
3737
@@index([status])
3838
@@index([createdAt])
3939
@@index([policyId])
40+
@@index([createdAt, id]) // keyset pagination: ORDER BY createdAt DESC, id DESC
4041
@@map("claims")
4142
}
4243

@@ -85,6 +86,7 @@ model Policy {
8586
@@unique([holderAddress, policyId])
8687
@@index([holderAddress])
8788
@@index([isActive])
89+
@@index([createdAt, id]) // keyset pagination: ORDER BY createdAt DESC, id DESC
8890
@@map("policies")
8991
}
9092

backend/src/claims/claims.controller.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ApiBearerAuth,
1919
ApiQuery,
2020
} from '@nestjs/swagger';
21+
import { Throttle } from '@nestjs/throttler';
2122
import { ClaimsService } from './claims.service';
2223
import { ClaimsListResponseDto, ClaimDetailResponseDto } from './dto/claim.dto';
2324
import { BuildClaimTransactionDto } from './dto/build-claim-transaction.dto';
@@ -32,34 +33,60 @@ export class ClaimsController {
3233
constructor(private readonly claimsService: ClaimsService) {}
3334

3435
@Get()
35-
@ApiOperation({ summary: 'List all claims with aggregated data' })
36-
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
37-
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20, max: 100)' })
38-
@ApiQuery({ name: 'status', required: false, enum: ['pending', 'approved', 'rejected'], description: 'Filter by status' })
36+
@ApiOperation({ summary: 'List claims with cursor-based pagination' })
37+
@ApiQuery({
38+
name: 'after',
39+
required: false,
40+
type: String,
41+
description: 'Opaque cursor from a previous response next_cursor. Omit for the first page.',
42+
})
43+
@ApiQuery({
44+
name: 'limit',
45+
required: false,
46+
type: Number,
47+
description: `Items per page. Clamped to [1, ${MAX_LIMIT}]. Default ${DEFAULT_LIMIT}.`,
48+
})
49+
@ApiQuery({
50+
name: 'status',
51+
required: false,
52+
enum: ['pending', 'approved', 'rejected', 'paid'],
53+
description: 'Filter by claim status.',
54+
})
3955
@ApiResponse({ status: 200, description: 'Paginated list of claims', type: ClaimsListResponseDto })
56+
@ApiResponse({ status: 400, description: 'Invalid cursor' })
4057
async listClaims(
41-
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
42-
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
58+
@Query('after') after?: string,
59+
@Query('limit', new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit?: number,
4360
@Query('status') status?: string,
4461
): Promise<ClaimsListResponseDto> {
45-
// Cap limit at 100
46-
const cappedLimit = Math.min(limit, 100);
47-
return this.claimsService.listClaims({ page, limit: cappedLimit, status });
62+
return this.claimsService.listClaims({ after, limit, status });
4863
}
4964

5065
@Get('needs-my-vote')
5166
@UseGuards(JwtAuthGuard)
5267
@ApiBearerAuth()
5368
@ApiOperation({ summary: 'Get claims requiring the authenticated user to vote' })
69+
@ApiQuery({
70+
name: 'after',
71+
required: false,
72+
type: String,
73+
description: 'Opaque cursor from a previous response next_cursor.',
74+
})
75+
@ApiQuery({
76+
name: 'limit',
77+
required: false,
78+
type: Number,
79+
description: `Items per page. Clamped to [1, ${MAX_LIMIT}]. Default ${DEFAULT_LIMIT}.`,
80+
})
5481
@ApiResponse({ status: 200, description: 'Claims where user has not voted yet', type: ClaimsListResponseDto })
82+
@ApiResponse({ status: 400, description: 'Invalid cursor' })
5583
@ApiResponse({ status: 401, description: 'Unauthorized' })
5684
async getClaimsNeedingMyVote(
5785
@WalletAddress() walletAddress: string,
58-
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
59-
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
86+
@Query('after') after?: string,
87+
@Query('limit', new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit?: number,
6088
): Promise<ClaimsListResponseDto> {
61-
const cappedLimit = Math.min(limit, 100);
62-
return this.claimsService.getClaimsNeedingVote(walletAddress, { page, limit: cappedLimit });
89+
return this.claimsService.getClaimsNeedingVote(walletAddress, { after, limit });
6390
}
6491

6592
@Get(':id')

backend/src/claims/claims.service.ts

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ import {
1212
SanitizedEvidenceDto,
1313
VoteTalliesDto,
1414
} from './dto/claim.dto';
15+
import {
16+
buildKeysetWhere,
17+
buildNextCursor,
18+
clampLimit,
19+
} from '../helpers/pagination';
1520

1621
interface ListClaimsParams {
17-
page: number;
18-
limit: number;
22+
after?: string;
23+
limit?: number;
1924
status?: string;
2025
}
2126

@@ -47,9 +52,9 @@ export class ClaimsService {
4752
}
4853

4954
async listClaims(params: ListClaimsParams): Promise<ClaimsListResponseDto> {
50-
const { page, limit, status } = params;
51-
const skip = (page - 1) * limit;
52-
const cacheKey = `claims:list:${page}:${limit}:${status || 'all'}`;
55+
const { after, status } = params;
56+
const limit = clampLimit(params.limit);
57+
const cacheKey = `claims:list:${after ?? 'start'}:${limit}:${status ?? 'all'}`;
5358
const cached = await this.redis.get<ClaimsListResponseDto>(cacheKey);
5459

5560
if (cached) {
@@ -58,33 +63,30 @@ export class ClaimsService {
5863
}
5964

6065
const lastLedger = await this.getLastLedger();
61-
const where: Prisma.ClaimWhereInput | undefined = status
66+
const statusFilter = status
6267
? { status: status.toUpperCase() as 'PENDING' | 'APPROVED' | 'PAID' | 'REJECTED' }
63-
: undefined;
68+
: {};
69+
const keysetWhere = buildKeysetWhere(after);
70+
const where: Prisma.ClaimWhereInput = {
71+
...statusFilter,
72+
...(keysetWhere ?? {}),
73+
};
6474

6575
const [claims, total] = await Promise.all([
6676
this.prisma.claim.findMany({
6777
where,
68-
include: {
69-
votes: {
70-
select: { vote: true },
71-
},
72-
},
73-
orderBy: { createdAt: 'desc' },
74-
skip,
78+
include: { votes: { select: { vote: true } } },
79+
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
7580
take: limit,
7681
}),
77-
this.prisma.claim.count({ where }),
82+
this.prisma.claim.count({ where: statusFilter }),
7883
]);
7984

8085
const response: ClaimsListResponseDto = {
8186
data: claims.map((claim) => this.transformClaim(claim, lastLedger)),
8287
pagination: {
83-
page,
84-
limit,
88+
next_cursor: buildNextCursor(claims, limit, total),
8589
total,
86-
totalPages: Math.ceil(total / limit),
87-
hasNext: skip + claims.length < total,
8890
},
8991
};
9092

@@ -96,42 +98,41 @@ export class ClaimsService {
9698
walletAddress: string,
9799
params: ListClaimsParams,
98100
): Promise<ClaimsListResponseDto> {
99-
const { page, limit } = params;
100-
const skip = (page - 1) * limit;
101+
const { after } = params;
102+
const limit = clampLimit(params.limit);
101103
const lastLedger = await this.getLastLedger();
102104

103105
const votedClaimIds = await this.prisma.vote.findMany({
104106
where: { voterAddress: walletAddress.toLowerCase() },
105107
select: { claimId: true },
106108
});
107-
const votedIds = votedClaimIds.map((vote) => vote.claimId);
109+
const votedIds = votedClaimIds.map((v) => v.claimId);
110+
const keysetWhere = buildKeysetWhere(after);
108111

109-
const pendingClaims = await this.prisma.claim.findMany({
110-
where: {
111-
status: 'PENDING',
112-
...(votedIds.length > 0 ? { id: { notIn: votedIds } } : {}),
113-
},
114-
include: {
115-
votes: {
116-
select: { vote: true },
117-
},
118-
},
119-
orderBy: { createdAt: 'desc' },
120-
});
112+
const baseWhere: Prisma.ClaimWhereInput = {
113+
status: 'PENDING',
114+
...(votedIds.length > 0 ? { id: { notIn: votedIds } } : {}),
115+
};
116+
117+
const [allOpen, page] = await Promise.all([
118+
this.prisma.claim.count({ where: baseWhere }),
119+
this.prisma.claim.findMany({
120+
where: { ...baseWhere, ...(keysetWhere ?? {}) },
121+
include: { votes: { select: { vote: true } } },
122+
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
123+
take: limit,
124+
}),
125+
]);
121126

122-
const openClaims = pendingClaims.filter(
127+
const openClaims = page.filter(
123128
(claim) => this.getVotingDeadlineLedger(claim.createdAtLedger) > lastLedger,
124129
);
125-
const paginatedClaims = openClaims.slice(skip, skip + limit);
126130

127131
return {
128-
data: paginatedClaims.map((claim) => this.transformClaim(claim, lastLedger)),
132+
data: openClaims.map((claim) => this.transformClaim(claim, lastLedger)),
129133
pagination: {
130-
page,
131-
limit,
132-
total: openClaims.length,
133-
totalPages: Math.ceil(openClaims.length / limit),
134-
hasNext: skip + paginatedClaims.length < openClaims.length,
134+
next_cursor: buildNextCursor(openClaims, limit, allOpen),
135+
total: allOpen,
135136
},
136137
};
137138
}

backend/src/claims/dto/claim.dto.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,36 +151,34 @@ export class ClaimListItemDto {
151151
consistency!: ConsistencyMetadataDto;
152152
}
153153

154-
export class PaginationDto {
155-
@ApiProperty({ description: 'Current page number' })
156-
@Expose()
157-
page!: number;
158-
159-
@ApiProperty({ description: 'Items per page' })
160-
@Expose()
161-
limit!: number;
162-
163-
@ApiProperty({ description: 'Total items' })
154+
export class CursorPageDto {
155+
@ApiProperty({
156+
description:
157+
'Opaque cursor to pass as `after` for the next page. Null when this is the last page.',
158+
nullable: true,
159+
example: 'eyJjcmVhdGVkQXQiOiIyMDI0LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJpZCI6NDJ9',
160+
})
161+
@Expose()
162+
next_cursor!: string | null;
163+
164+
@ApiProperty({
165+
description:
166+
'Total rows matching the filter before pagination. ' +
167+
'Eventually consistent — may differ by ±1 under concurrent inserts.',
168+
example: 42,
169+
})
164170
@Expose()
165171
total!: number;
166-
167-
@ApiProperty({ description: 'Total pages' })
168-
@Expose()
169-
totalPages!: number;
170-
171-
@ApiProperty({ description: 'Has next page' })
172-
@Expose()
173-
hasNext!: boolean;
174172
}
175173

176174
export class ClaimsListResponseDto {
177175
@ApiProperty({ description: 'Array of claims', type: [ClaimListItemDto] })
178176
@Expose()
179177
data!: ClaimListItemDto[];
180178

181-
@ApiProperty({ description: 'Pagination info', type: PaginationDto })
179+
@ApiProperty({ description: 'Cursor pagination metadata', type: CursorPageDto })
182180
@Expose()
183-
pagination!: PaginationDto;
181+
pagination!: CursorPageDto;
184182
}
185183

186184
export class ClaimDetailResponseDto extends ClaimListItemDto {

0 commit comments

Comments
 (0)