Skip to content

Commit 46bffed

Browse files
committed
feat(api): implement server-side pagination on list endpoints
1 parent 4fce8c0 commit 46bffed

8 files changed

Lines changed: 192 additions & 92 deletions

File tree

backend/src/index.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
softDeleteCampaign,
3030
reconcileOnChainPledge,
3131
refundContributor,
32-
updateCampaign,
3332
} from "./services/campaignStore";
3433
import { checkDbHealth } from "./services/db";
3534
import { getCampaignHistory } from "./services/eventHistory";
@@ -46,7 +45,6 @@ import {
4645
parsePledgeListPaginationQuery,
4746
reconcilePledgePayloadSchema,
4847
refundPayloadSchema,
49-
updateCampaignPayloadSchema,
5048
zodIssuesToErrorMessage,
5149
zodIssuesToValidationIssues,
5250
} from "./validation/schemas";
@@ -245,30 +243,28 @@ app.get("/api/health", (_req: Request, res: Response) => {
245243
app.get("/api/campaigns", (req: Request, res: Response) => {
246244
const paginationResult = parseCampaignListPaginationQuery({
247245
page: req.query.page,
248-
limit: req.query.limit,
246+
pageSize: req.query.pageSize,
249247
});
250248
if (!paginationResult.ok) {
251249
sendValidationError(paginationResult.issues);
252250
}
253251

254-
const filters = parseCampaignListFilters({
255-
asset: req.query.asset,
256-
status: req.query.status,
257-
q: req.query.q,
258-
search: req.query.search,
259-
includeDeleted: req.query.includeDeleted,
260-
});
252+
const filters = parseCampaignListFilters({
253+
asset: req.query.asset,
254+
status: req.query.status,
255+
q: req.query.q,
256+
search: req.query.search,
257+
includeDeleted: req.query.includeDeleted,
258+
});
261259

262260
const listOptions: ListCampaignsOptions = {
263261
searchQuery: filters.searchQuery,
264262
assetCode: filters.asset,
265263
status: filters.status,
266264
includeDeleted: filters.includeDeleted,
265+
page: paginationResult.page,
266+
limit: paginationResult.pageSize,
267267
};
268-
if (paginationResult.page !== undefined) {
269-
listOptions.page = paginationResult.page;
270-
listOptions.limit = paginationResult.limit;
271-
}
272268

273269
const { campaigns, totalCount } = listCampaigns(listOptions);
274270

@@ -280,21 +276,14 @@ app.get("/api/campaigns", (req: Request, res: Response) => {
280276
filters,
281277
);
282278

283-
const page = paginationResult.page ?? 1;
284-
const limit = paginationResult.limit ?? totalCount;
285-
const totalPages =
286-
paginationResult.limit === undefined || limit <= 0
287-
? 1
288-
: Math.max(1, Math.ceil(totalCount / limit));
279+
const hasMore = paginationResult.page * paginationResult.pageSize < totalCount;
289280

290281
res.json({
291282
data,
292-
pagination: {
293-
total: totalCount,
294-
page,
295-
limit,
296-
totalPages,
297-
},
283+
total: totalCount,
284+
page: paginationResult.page,
285+
pageSize: paginationResult.pageSize,
286+
hasMore,
298287
});
299288
});
300289

backend/src/services/campaignStore.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,9 @@ export function listCampaigns(
348348
options?: ListCampaignsOptions,
349349
): ListCampaignsResult {
350350
const db = getDb();
351-
const paginate = options?.page !== undefined && options?.limit !== undefined;
352351
const page = options?.page ?? 1;
353-
const limit = options?.limit ?? 10;
354-
const offset = paginate ? (page - 1) * limit : 0;
352+
const limit = options?.limit ?? 20;
353+
const offset = (page - 1) * limit;
355354

356355
const whereClauses: string[] = [];
357356
const params: any[] = [];
@@ -408,14 +407,8 @@ export function listCampaigns(
408407
db.prepare(countQuery).get(...params) as { total: number }
409408
).total;
410409

411-
const dataQuery = paginate
412-
? `SELECT * ${baseQuery} ORDER BY created_at DESC LIMIT ? OFFSET ?`
413-
: `SELECT * ${baseQuery} ORDER BY created_at DESC`;
414-
const rows = (
415-
paginate
416-
? db.prepare(dataQuery).all(...params, limit, offset)
417-
: db.prepare(dataQuery).all(...params)
418-
) as CampaignRow[];
410+
const dataQuery = `SELECT * ${baseQuery} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
411+
const rows = db.prepare(dataQuery).all(...params, limit, offset) as CampaignRow[];
419412

420413
return {
421414
campaigns: rows.map(rowToCampaign),

backend/src/validation/schemas.ts

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -161,35 +161,18 @@ function parsePositiveIntegerQueryParam(
161161
}
162162

163163
/**
164-
* Parses optional `page` and `limit` for GET /api/campaigns.
165-
* Omitting both means no pagination (caller lists the full filtered set).
166-
* Supplying only one is invalid (400).
164+
* Parses optional `page` and `pageSize` for GET /api/campaigns.
165+
* Defaults: page=1, pageSize=20. Max pageSize=100.
167166
*/
168167
export function parseCampaignListPaginationQuery(query: {
169168
page?: unknown;
170-
limit?: unknown;
171-
}): { ok: true; page?: number; limit?: number } | { ok: false; issues: z.core.$ZodIssue[] } {
169+
pageSize?: unknown;
170+
}): { ok: true; page: number; pageSize: number } | { ok: false; issues: z.core.$ZodIssue[] } {
172171
const pageStr = singleCampaignListQueryParam(query.page);
173-
const limitStr = singleCampaignListQueryParam(query.limit);
174-
175-
if (pageStr === undefined && limitStr === undefined) {
176-
return { ok: true };
177-
}
178-
if (pageStr === undefined || limitStr === undefined) {
179-
return {
180-
ok: false,
181-
issues: [
182-
{
183-
code: "custom",
184-
message: "Pagination requires both page and limit query parameters.",
185-
path: pageStr === undefined ? ["page"] : ["limit"],
186-
},
187-
],
188-
};
189-
}
172+
const pageSizeStr = singleCampaignListQueryParam(query.pageSize);
190173

191-
const pageNum = Number(pageStr);
192-
const limitNum = Number(limitStr);
174+
const pageNum = pageStr ? Number(pageStr) : 1;
175+
const pageSizeNum = pageSizeStr ? Number(pageSizeStr) : 20;
193176
const issues: z.core.$ZodIssue[] = [];
194177

195178
if (!Number.isFinite(pageNum) || !Number.isInteger(pageNum) || pageNum < 1) {
@@ -200,23 +183,23 @@ export function parseCampaignListPaginationQuery(query: {
200183
});
201184
}
202185
if (
203-
!Number.isFinite(limitNum) ||
204-
!Number.isInteger(limitNum) ||
205-
limitNum < 1 ||
206-
limitNum > 100
186+
!Number.isFinite(pageSizeNum) ||
187+
!Number.isInteger(pageSizeNum) ||
188+
pageSizeNum < 1 ||
189+
pageSizeNum > 100
207190
) {
208191
issues.push({
209192
code: "custom",
210-
message: "limit must be an integer from 1 to 100.",
211-
path: ["limit"],
193+
message: "pageSize must be an integer from 1 to 100.",
194+
path: ["pageSize"],
212195
});
213196
}
214197

215198
if (issues.length > 0) {
216199
return { ok: false, issues };
217200
}
218201

219-
return { ok: true, page: pageNum, limit: limitNum };
202+
return { ok: true, page: pageNum, pageSize: pageSizeNum };
220203
}
221204

222205
export function parsePledgeListPaginationQuery(query: {

backend/tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"compilerOptions": {
33
"target": "ES2020",
4-
"module": "commonjs",
5-
"moduleResolution": "node",
4+
"module": "node16",
5+
"moduleResolution": "node16",
66
"lib": ["ES2020"],
77
"outDir": "./dist",
88
"rootDir": "./src",

frontend/src/App.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
reconcilePledge,
2626
refundCampaign,
2727
softDeleteCampaign,
28+
CampaignListResponse,
2829
} from "./services/api";
2930
import {
3031
submitFreighterClaim,
@@ -123,6 +124,7 @@ function App() {
123124
const connectedWallet = freighter.publicKey;
124125

125126
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
127+
const [campaignsPagination, setCampaignsPagination] = useState<{ total: number; page: number; pageSize: number; hasMore: boolean }>({ total: 0, page: 1, pageSize: 20, hasMore: false });
126128
const [issues, setIssues] = useState<OpenIssue[]>([]);
127129
const [history, setHistory] = useState<CampaignEvent[]>([]);
128130
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
@@ -189,16 +191,22 @@ function App() {
189191
};
190192
}, [transactionPreview]);
191193

192-
async function refreshCampaigns(searchQuery: string = '', nextSelectedId?: string | null): Promise<Campaign[]> {
194+
async function refreshCampaigns(searchQuery: string = '', nextSelectedId?: string | null, page: number = 1): Promise<Campaign[]> {
193195
setIsCampaignsLoading(true);
194196
try {
195-
const data = await listCampaigns({ search: searchQuery });
196-
setCampaigns(data);
197+
const response = await listCampaigns({ search: searchQuery, page, pageSize: 20 });
198+
setCampaigns(response.data);
199+
setCampaignsPagination({
200+
total: response.total,
201+
page: response.page,
202+
pageSize: response.pageSize,
203+
hasMore: response.hasMore,
204+
});
197205

198206
const requestedId = nextSelectedId ?? selectedCampaignId;
199-
const nextId = requestedId ?? data[0]?.id ?? null;
200-
const exists = nextId ? data.some((campaign) => campaign.id === nextId) : false;
201-
const resolvedId = exists ? nextId : data[0]?.id ?? null;
207+
const nextId = requestedId ?? response.data[0]?.id ?? null;
208+
const exists = nextId ? response.data.some((campaign) => campaign.id === nextId) : false;
209+
const resolvedId = exists ? nextId : response.data[0]?.id ?? null;
202210

203211
setInvalidUrlCampaignId(requestedId && !exists ? requestedId : null);
204212
setSelectedCampaignId(resolvedId);
@@ -208,7 +216,7 @@ function App() {
208216
setHistory([]);
209217
}
210218

211-
return data;
219+
return response.data;
212220
} finally {
213221
setIsCampaignsLoading(false);
214222
}
@@ -253,7 +261,7 @@ function App() {
253261
const [configResult, issuesResult, campaignsResult] = await Promise.allSettled([
254262
getAppConfig(),
255263
listOpenIssues(),
256-
listCampaigns({ search: '' }),
264+
listCampaigns({ search: '', page: 1, pageSize: 20 }),
257265
]);
258266

259267
if (cancelled) {
@@ -271,12 +279,18 @@ function App() {
271279
}
272280

273281
if (campaignsResult.status === "fulfilled") {
274-
const data = campaignsResult.value;
275-
setCampaigns(data);
282+
const response = campaignsResult.value;
283+
setCampaigns(response.data);
284+
setCampaignsPagination({
285+
total: response.total,
286+
page: response.page,
287+
pageSize: response.pageSize,
288+
hasMore: response.hasMore,
289+
});
276290

277-
const nextId = requestedCampaignId ?? data[0]?.id ?? null;
278-
const exists = nextId ? data.some((campaign) => campaign.id === nextId) : false;
279-
const resolvedId = exists ? nextId : data[0]?.id ?? null;
291+
const nextId = requestedCampaignId ?? response.data[0]?.id ?? null;
292+
const exists = nextId ? response.data.some((campaign) => campaign.id === nextId) : false;
293+
const resolvedId = exists ? nextId : response.data[0]?.id ?? null;
280294

281295
setInvalidUrlCampaignId(requestedCampaignId && !exists ? requestedCampaignId : null);
282296
setSelectedCampaignId(resolvedId);
@@ -633,10 +647,16 @@ function App() {
633647
selectedCampaignId={selectedCampaignId}
634648
onSelect={handleSelect}
635649
onSearchChange={(query) => {
636-
void refreshCampaigns(query);
650+
void refreshCampaigns(query, undefined, 1);
651+
}}
652+
onPageChange={(page) => {
653+
void refreshCampaigns("", undefined, page);
637654
}}
638655
isLoading={isCampaignsLoading || initialLoad}
639656
invalidUrlCampaignId={invalidUrlCampaignId}
657+
currentPage={campaignsPagination.page}
658+
hasMore={campaignsPagination.hasMore}
659+
totalCampaigns={campaignsPagination.total}
640660
/>
641661
</ErrorBoundary>
642662

frontend/src/components/CampaignsTable.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { LayoutGrid } from "lucide-react";
2-
import { useMemo, useState } from "react";
2+
import { useEffect, useMemo, useState } from "react";
33
import { useDebounce } from "../hooks/useDebounce";
44
import { Campaign, CampaignStatus } from "../types/campaign";
55
import { EmptyState } from "./EmptyState";
@@ -28,8 +28,12 @@ interface CampaignsTableProps {
2828
selectedCampaignId: string | null;
2929
onSelect: (campaignId: string) => void;
3030
onSearchChange?: (query: string) => void;
31+
onPageChange?: (page: number) => void;
3132
isLoading?: boolean;
3233
invalidUrlCampaignId?: string | null;
34+
currentPage?: number;
35+
hasMore?: boolean;
36+
totalCampaigns?: number;
3337
}
3438

3539
function formatTimestamp(value: number | string): string {
@@ -58,8 +62,13 @@ export function CampaignsTable({
5862
campaigns,
5963
selectedCampaignId,
6064
onSelect,
65+
onSearchChange,
66+
onPageChange,
6167
isLoading = false,
6268
invalidUrlCampaignId = null,
69+
currentPage = 1,
70+
hasMore = false,
71+
totalCampaigns = 0,
6372
}: CampaignsTableProps) {
6473
const [assetCode, setAssetCode] = useState("");
6574
const [statusFilter, setStatusFilter] = useState<StatusFilterValue>("");
@@ -360,6 +369,34 @@ export function CampaignsTable({
360369
</div>
361370
</>
362371
)}
372+
373+
{totalCampaigns > 0 && (
374+
<div className="pagination-controls" style={{ marginTop: "1rem", display: "flex", gap: "1rem", alignItems: "center", justifyContent: "space-between" }}>
375+
<div className="muted" style={{ fontSize: "0.875rem" }}>
376+
Showing page {currentPage} ({campaigns.length} campaigns, {totalCampaigns} total)
377+
</div>
378+
<div style={{ display: "flex", gap: "0.5rem" }}>
379+
<button
380+
className="btn-ghost"
381+
type="button"
382+
onClick={() => onPageChange?.(currentPage - 1)}
383+
disabled={isLoading || currentPage <= 1}
384+
aria-label="Previous page"
385+
>
386+
← Previous
387+
</button>
388+
<button
389+
className="btn-ghost"
390+
type="button"
391+
onClick={() => onPageChange?.(currentPage + 1)}
392+
disabled={isLoading || !hasMore}
393+
aria-label="Next page"
394+
>
395+
Next →
396+
</button>
397+
</div>
398+
</div>
399+
)}
363400
</section>
364401
);
365402
}

0 commit comments

Comments
 (0)