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
2 changes: 2 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ model Claim {
@@index([status])
@@index([createdAt])
@@index([policyId])
@@index([createdAt, id]) // keyset pagination: ORDER BY createdAt DESC, id DESC
@@map("claims")
}

Expand Down Expand Up @@ -85,6 +86,7 @@ model Policy {
@@unique([holderAddress, policyId])
@@index([holderAddress])
@@index([isActive])
@@index([createdAt, id]) // keyset pagination: ORDER BY createdAt DESC, id DESC
@@map("policies")
}

Expand Down
53 changes: 40 additions & 13 deletions backend/src/claims/claims.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ClaimsService } from './claims.service';
import { ClaimsListResponseDto, ClaimDetailResponseDto } from './dto/claim.dto';
import { BuildClaimTransactionDto } from './dto/build-claim-transaction.dto';
Expand All @@ -32,34 +33,60 @@ export class ClaimsController {
constructor(private readonly claimsService: ClaimsService) {}

@Get()
@ApiOperation({ summary: 'List all claims with aggregated data' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 20, max: 100)' })
@ApiQuery({ name: 'status', required: false, enum: ['pending', 'approved', 'rejected'], description: 'Filter by status' })
@ApiOperation({ summary: 'List claims with cursor-based pagination' })
@ApiQuery({
name: 'after',
required: false,
type: String,
description: 'Opaque cursor from a previous response next_cursor. Omit for the first page.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: `Items per page. Clamped to [1, ${MAX_LIMIT}]. Default ${DEFAULT_LIMIT}.`,
})
@ApiQuery({
name: 'status',
required: false,
enum: ['pending', 'approved', 'rejected', 'paid'],
description: 'Filter by claim status.',
})
@ApiResponse({ status: 200, description: 'Paginated list of claims', type: ClaimsListResponseDto })
@ApiResponse({ status: 400, description: 'Invalid cursor' })
async listClaims(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query('after') after?: string,
@Query('limit', new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit?: number,
@Query('status') status?: string,
): Promise<ClaimsListResponseDto> {
// Cap limit at 100
const cappedLimit = Math.min(limit, 100);
return this.claimsService.listClaims({ page, limit: cappedLimit, status });
return this.claimsService.listClaims({ after, limit, status });
}

@Get('needs-my-vote')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get claims requiring the authenticated user to vote' })
@ApiQuery({
name: 'after',
required: false,
type: String,
description: 'Opaque cursor from a previous response next_cursor.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: `Items per page. Clamped to [1, ${MAX_LIMIT}]. Default ${DEFAULT_LIMIT}.`,
})
@ApiResponse({ status: 200, description: 'Claims where user has not voted yet', type: ClaimsListResponseDto })
@ApiResponse({ status: 400, description: 'Invalid cursor' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getClaimsNeedingMyVote(
@WalletAddress() walletAddress: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query('after') after?: string,
@Query('limit', new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit?: number,
): Promise<ClaimsListResponseDto> {
const cappedLimit = Math.min(limit, 100);
return this.claimsService.getClaimsNeedingVote(walletAddress, { page, limit: cappedLimit });
return this.claimsService.getClaimsNeedingVote(walletAddress, { after, limit });
}

@Get(':id')
Expand Down
85 changes: 43 additions & 42 deletions backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import {
SanitizedEvidenceDto,
VoteTalliesDto,
} from './dto/claim.dto';
import {
buildKeysetWhere,
buildNextCursor,
clampLimit,
} from '../helpers/pagination';

interface ListClaimsParams {
page: number;
limit: number;
after?: string;
limit?: number;
status?: string;
}

Expand Down Expand Up @@ -47,9 +52,9 @@ export class ClaimsService {
}

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

if (cached) {
Expand All @@ -58,33 +63,30 @@ export class ClaimsService {
}

const lastLedger = await this.getLastLedger();
const where: Prisma.ClaimWhereInput | undefined = status
const statusFilter = status
? { status: status.toUpperCase() as 'PENDING' | 'APPROVED' | 'PAID' | 'REJECTED' }
: undefined;
: {};
const keysetWhere = buildKeysetWhere(after);
const where: Prisma.ClaimWhereInput = {
...statusFilter,
...(keysetWhere ?? {}),
};

const [claims, total] = await Promise.all([
this.prisma.claim.findMany({
where,
include: {
votes: {
select: { vote: true },
},
},
orderBy: { createdAt: 'desc' },
skip,
include: { votes: { select: { vote: true } } },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit,
}),
this.prisma.claim.count({ where }),
this.prisma.claim.count({ where: statusFilter }),
]);

const response: ClaimsListResponseDto = {
data: claims.map((claim) => this.transformClaim(claim, lastLedger)),
pagination: {
page,
limit,
next_cursor: buildNextCursor(claims, limit, total),
total,
totalPages: Math.ceil(total / limit),
hasNext: skip + claims.length < total,
},
};

Expand All @@ -96,42 +98,41 @@ export class ClaimsService {
walletAddress: string,
params: ListClaimsParams,
): Promise<ClaimsListResponseDto> {
const { page, limit } = params;
const skip = (page - 1) * limit;
const { after } = params;
const limit = clampLimit(params.limit);
const lastLedger = await this.getLastLedger();

const votedClaimIds = await this.prisma.vote.findMany({
where: { voterAddress: walletAddress.toLowerCase() },
select: { claimId: true },
});
const votedIds = votedClaimIds.map((vote) => vote.claimId);
const votedIds = votedClaimIds.map((v) => v.claimId);
const keysetWhere = buildKeysetWhere(after);

const pendingClaims = await this.prisma.claim.findMany({
where: {
status: 'PENDING',
...(votedIds.length > 0 ? { id: { notIn: votedIds } } : {}),
},
include: {
votes: {
select: { vote: true },
},
},
orderBy: { createdAt: 'desc' },
});
const baseWhere: Prisma.ClaimWhereInput = {
status: 'PENDING',
...(votedIds.length > 0 ? { id: { notIn: votedIds } } : {}),
};

const [allOpen, page] = await Promise.all([
this.prisma.claim.count({ where: baseWhere }),
this.prisma.claim.findMany({
where: { ...baseWhere, ...(keysetWhere ?? {}) },
include: { votes: { select: { vote: true } } },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit,
}),
]);

const openClaims = pendingClaims.filter(
const openClaims = page.filter(
(claim) => this.getVotingDeadlineLedger(claim.createdAtLedger) > lastLedger,
);
const paginatedClaims = openClaims.slice(skip, skip + limit);

return {
data: paginatedClaims.map((claim) => this.transformClaim(claim, lastLedger)),
data: openClaims.map((claim) => this.transformClaim(claim, lastLedger)),
pagination: {
page,
limit,
total: openClaims.length,
totalPages: Math.ceil(openClaims.length / limit),
hasNext: skip + paginatedClaims.length < openClaims.length,
next_cursor: buildNextCursor(openClaims, limit, allOpen),
total: allOpen,
},
};
}
Expand Down
38 changes: 18 additions & 20 deletions backend/src/claims/dto/claim.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,36 +151,34 @@ export class ClaimListItemDto {
consistency!: ConsistencyMetadataDto;
}

export class PaginationDto {
@ApiProperty({ description: 'Current page number' })
@Expose()
page!: number;

@ApiProperty({ description: 'Items per page' })
@Expose()
limit!: number;

@ApiProperty({ description: 'Total items' })
export class CursorPageDto {
@ApiProperty({
description:
'Opaque cursor to pass as `after` for the next page. Null when this is the last page.',
nullable: true,
example: 'eyJjcmVhdGVkQXQiOiIyMDI0LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJpZCI6NDJ9',
})
@Expose()
next_cursor!: string | null;

@ApiProperty({
description:
'Total rows matching the filter before pagination. ' +
'Eventually consistent — may differ by ±1 under concurrent inserts.',
example: 42,
})
@Expose()
total!: number;

@ApiProperty({ description: 'Total pages' })
@Expose()
totalPages!: number;

@ApiProperty({ description: 'Has next page' })
@Expose()
hasNext!: boolean;
}

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

@ApiProperty({ description: 'Pagination info', type: PaginationDto })
@ApiProperty({ description: 'Cursor pagination metadata', type: CursorPageDto })
@Expose()
pagination!: PaginationDto;
pagination!: CursorPageDto;
}

export class ClaimDetailResponseDto extends ClaimListItemDto {
Expand Down
Loading
Loading