diff --git a/backend/src/routes/getTransactionHistory.ts b/backend/src/routes/getTransactionHistory.ts index 6346de7d..5907ccd7 100644 --- a/backend/src/routes/getTransactionHistory.ts +++ b/backend/src/routes/getTransactionHistory.ts @@ -2,13 +2,30 @@ import { and, desc, eq, lt, or, sql } from 'drizzle-orm' import type { FastifyInstance } from 'fastify' import { usdcTransfer } from '@/db/schema' +import { fromCursorHash, toCursorHash } from '@/utils/pagination' import { addressRegex } from '.' +const MAX_PAGE_SIZE = 20 + +function getCursorQuery(cursor?: string): Parameters { + const [transferId, timestamp] = fromCursorHash(cursor) + + return [ + or( + and( + timestamp ? eq(usdcTransfer.blockTimestamp, new Date(Number(timestamp) * 1000)) : undefined, + transferId ? lt(usdcTransfer.transferId, transferId) : undefined, + ), + timestamp ? lt(usdcTransfer.blockTimestamp, new Date(Number(timestamp) * 1000)) : undefined, + ), + ] +} + interface TransactionHistoryQuery { - address: string - first: number - after?: number + address?: string + first?: string + after?: string } export function getTransactionHistory(fastify: FastifyInstance) { @@ -16,14 +33,15 @@ export function getTransactionHistory(fastify: FastifyInstance) { '/transaction_history', async (request, reply) => { - const { address, first, after } = request.query as TransactionHistoryQuery + const { address, first: firstStr, after } = request.query as TransactionHistoryQuery + const first = Number(firstStr ?? MAX_PAGE_SIZE) if (!address) { return reply.status(400).send({ error: 'Address is required.' }) } - if (!first) { - return reply.status(400).send({ error: 'First is required.' }) + if (first > MAX_PAGE_SIZE) { + return reply.status(400).send({ error: `First cannot exceed ${MAX_PAGE_SIZE}.` }) } // Validate address format @@ -31,13 +49,14 @@ export function getTransactionHistory(fastify: FastifyInstance) { return reply.status(400).send({ error: 'Invalid address format.' }) } - const firstTimestamp = after ? Number(after) : 0xfffffffffff // a timestamp large enough to be sure that it's in the future + const afterQuery = getCursorQuery(after) try { const txs = await fastify.db .select({ transaction_timestamp: usdcTransfer.blockTimestamp, amount: usdcTransfer.amount, + transfer_id: usdcTransfer.transferId, from: { nickname: sql`"from_user"."nickname"`, contract_address: sql`"from_user"."contract_address"`, @@ -52,17 +71,21 @@ export function getTransactionHistory(fastify: FastifyInstance) { .from(usdcTransfer) .leftJoin(sql`registration AS "from_user"`, eq(usdcTransfer.fromAddress, sql`"from_user"."contract_address"`)) .leftJoin(sql`registration AS "to_user"`, eq(usdcTransfer.toAddress, sql`"to_user"."contract_address"`)) - .where( - and( - lt(usdcTransfer.blockTimestamp, new Date(firstTimestamp)), - or(eq(usdcTransfer.fromAddress, address), eq(usdcTransfer.toAddress, address)), - ), - ) - .limit(first) - .orderBy(desc(usdcTransfer.blockTimestamp)) + .where(and(...afterQuery, or(eq(usdcTransfer.fromAddress, address), eq(usdcTransfer.toAddress, address)))) + .limit(Number(first) + 1) + .orderBy(desc(usdcTransfer.blockTimestamp), desc(usdcTransfer.transferId)) .execute() - return reply.status(200).send({ transactions: txs }) + // get pagination infos + const lastTx = txs.length ? txs[Math.min(txs.length - 1, first - 1)] : null + + const endCursor = lastTx + ? toCursorHash(lastTx.transfer_id, (lastTx.transaction_timestamp!.getTime() / 1000).toString()) + : null + + const hasNext = txs.length > first + + return reply.status(200).send({ items: txs.slice(0, first), endCursor, hasNext }) } catch (error) { console.error(error) return reply.status(500).send({ error: 'Internal server error' }) diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts new file mode 100644 index 00000000..a6cdb29a --- /dev/null +++ b/backend/src/utils/pagination.ts @@ -0,0 +1,9 @@ +const CURSOR_SEPARATOR = '%' + +export function fromCursorHash(cursor?: string): string[] { + return cursor ? Buffer.from(cursor, 'base64').toString().split(CURSOR_SEPARATOR) : [] +} + +export function toCursorHash(...arr: string[]): string { + return Buffer.from(arr.join(CURSOR_SEPARATOR)).toString('base64') +} diff --git a/backend/test/getTransactionHistory.test.ts b/backend/test/getTransactionHistory.test.ts index aff1fd22..c18f24d9 100644 --- a/backend/test/getTransactionHistory.test.ts +++ b/backend/test/getTransactionHistory.test.ts @@ -121,8 +121,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, first, startValue, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(first) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(first) }) test('should return the first 9 entries', async () => { @@ -137,8 +137,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, first, startValue, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(first) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(first) }) test('should return all the txs', async () => { const txsNb = 10 @@ -152,8 +152,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, txsNb, 0, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(txsNb) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(txsNb) }) test('should return empty list unknown address', async () => { @@ -166,8 +166,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) - expect(response.json().transactions).toMatchObject([]) - expect(response.json().transactions).toHaveLength(0) + expect(response.json().items).toMatchObject([]) + expect(response.json().items).toHaveLength(0) }) test('should return an error wrong address format', async () => {