diff --git a/prisma/schema/trade.prisma b/prisma/schema/trade.prisma new file mode 100644 index 0000000..6883cb9 --- /dev/null +++ b/prisma/schema/trade.prisma @@ -0,0 +1,18 @@ +// prisma/schema/trade.prisma + +model Trade { + id String @id @default(cuid()) + buyer String + creatorId String + quantity String + price String + ledger Int + txHash String + timestamp DateTime + createdAt DateTime @default(now()) + + @@unique([ledger, txHash]) + @@index([creatorId]) + @@index([buyer]) + @@index([ledger]) +} diff --git a/src/modules/creators/creator-detail-holder-count-sequential.integration.test.ts b/src/modules/creators/creator-detail-holder-count-sequential.integration.test.ts new file mode 100644 index 0000000..3b6b6d6 --- /dev/null +++ b/src/modules/creators/creator-detail-holder-count-sequential.integration.test.ts @@ -0,0 +1,112 @@ +import { httpGetCreatorStats } from './creators.controllers'; +import { updateOwnership } from '../ownership/ownership.service'; +import { prisma } from '../../utils/prisma.utils'; + +function makeReq(creatorId: string): any { + return { + params: { id: creatorId }, + }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +describe('#630 Integration test: creator detail holder count after sequential buys and sells', () => { + const creatorId = '123'; + const walletA = 'GWALLETA1111111111111111111111111111111111111111111111111'; + const walletB = 'GWALLETB2222222222222222222222222222222222222222222222222'; + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('holder count updates accurately across sequential buys and sells across multiple wallets', async () => { + const ownershipStore = new Map(); + (prisma.creatorProfile.findFirst as any) = jest.fn(async () => ({ id: creatorId })); + (prisma.keyOwnership.count as any) = jest.fn(async (_args: any) => { + let count = 0; + for (const [key, bal] of ownershipStore.entries()) { + if (key.endsWith(`:${creatorId}`) && bal > 0) { + count++; + } + } + return count; + }); + + (prisma.keyOwnership.findFirst as any) = jest.fn(async (args: any) => { + const { ownerAddress, creatorId } = args.where; + const key = `${ownerAddress}:${creatorId}`; + const bal = ownershipStore.get(key) || 0; + return { balance: bal } as any; + }); + + (prisma.keyOwnership.upsert as any) = jest.fn(async (args: any) => { + const { ownerAddress, creatorId } = args.create; + const key = `${ownerAddress}:${creatorId}`; + const current = ownershipStore.get(key) || 0; + const change = args.update.balance.increment; + const newBal = current + change; + ownershipStore.set(key, newBal); + return { ownerAddress, creatorId, balance: newBal } as any; + }); + + + + // Step 0: Initial state - 0 holders + const req0 = makeReq(creatorId); + const res0 = makeRes(); + await httpGetCreatorStats(req0, res0, makeNext()); + expect(res0.json.mock.calls[0][0].data.holderCount).toBe(0); + expect(res0.json.mock.calls[0][0].data.holder_count).toBe(0); + + // Step 1: Wallet A buys 1 key -> holder count is 1 + await updateOwnership(walletA, creatorId, 1); + const req1 = makeReq(creatorId); + const res1 = makeRes(); + await httpGetCreatorStats(req1, res1, makeNext()); + expect(res1.json.mock.calls[0][0].data.holderCount).toBe(1); + expect(res1.json.mock.calls[0][0].data.holder_count).toBe(1); + + // Step 2: Wallet B buys 1 key -> holder count is 2 + await updateOwnership(walletB, creatorId, 1); + const req2 = makeReq(creatorId); + const res2 = makeRes(); + await httpGetCreatorStats(req2, res2, makeNext()); + expect(res2.json.mock.calls[0][0].data.holderCount).toBe(2); + expect(res2.json.mock.calls[0][0].data.holder_count).toBe(2); + + // Step 3: Wallet A buys 2 more keys -> holder count remains 2 (existing holder buying more keys) + await updateOwnership(walletA, creatorId, 2); + const req3 = makeReq(creatorId); + const res3 = makeRes(); + await httpGetCreatorStats(req3, res3, makeNext()); + expect(res3.json.mock.calls[0][0].data.holderCount).toBe(2); + expect(res3.json.mock.calls[0][0].data.holder_count).toBe(2); + + // Step 4: Wallet A sells its 3 keys -> holder count drops back to 1 + await updateOwnership(walletA, creatorId, -3); + const req4 = makeReq(creatorId); + const res4 = makeRes(); + await httpGetCreatorStats(req4, res4, makeNext()); + expect(res4.json.mock.calls[0][0].data.holderCount).toBe(1); + expect(res4.json.mock.calls[0][0].data.holder_count).toBe(1); + + // Step 5: Wallet B sells its 1 key -> holder count reaches 0 + await updateOwnership(walletB, creatorId, -1); + const req5 = makeReq(creatorId); + const res5 = makeRes(); + await httpGetCreatorStats(req5, res5, makeNext()); + expect(res5.json.mock.calls[0][0].data.holderCount).toBe(0); + expect(res5.json.mock.calls[0][0].data.holder_count).toBe(0); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 31a40f7..4a62796 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -117,21 +117,33 @@ function categorizeParseError( export const httpGetCreatorStats: AsyncController = async (req, res, next) => { try { const rawId = req.params.id; - const _creatorId = parseCreatorId( + const parsedId = parseCreatorId( Array.isArray(rawId) ? rawId[0] : rawId ); + const creatorIdStr = String(parsedId); - // TODO: Fetch actual creator metrics from database/service using _creatorId - // For now, return placeholder data - const placeholderMetrics = { - holderCount: 0, + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: creatorIdStr }, { handle: creatorIdStr }] }, + select: { id: true }, + }); + const resolvedId = creator ? creator.id : creatorIdStr; + + const holderCount = await prisma.keyOwnership.count({ + where: { + creatorId: resolvedId, + balance: { gt: 0 }, + }, + }); + + const metrics = { + holderCount, totalSupply: 0, totalVolume: 0, lastActivityAt: undefined, }; // Serialize using the public stats mapper - const stats = mapPublicCreatorStats(placeholderMetrics); + const stats = mapPublicCreatorStats(metrics); attachTimestampHeader(res); sendSuccess(res, stats); diff --git a/src/modules/creators/creators.stats.ts b/src/modules/creators/creators.stats.ts index 97eaa79..eea1925 100644 --- a/src/modules/creators/creators.stats.ts +++ b/src/modules/creators/creators.stats.ts @@ -24,6 +24,7 @@ export type CreatorStatsField = (typeof CREATOR_STATS_FIELDS)[number]; */ export interface PublicCreatorStats { holderCount: number; + holder_count: number; totalSupply: number; totalVolume: number; lastActivityAt?: Date; @@ -55,13 +56,15 @@ const CREATOR_STATS_FIELD_MAP = { * * @example * mapPublicCreatorStats({ holderCount: 10, totalSupply: 100, totalVolume: 500 }) - * // => { holderCount: 10, totalSupply: 100, totalVolume: 500 } + * // => { holderCount: 10, holder_count: 10, totalSupply: 100, totalVolume: 500 } */ export function mapPublicCreatorStats( metrics: CreatorMetrics ): PublicCreatorStats { + const count = metrics[CREATOR_STATS_FIELD_MAP.holderCount]; return { - holderCount: metrics[CREATOR_STATS_FIELD_MAP.holderCount], + holderCount: count, + holder_count: count, totalSupply: metrics[CREATOR_STATS_FIELD_MAP.totalSupply], totalVolume: metrics[CREATOR_STATS_FIELD_MAP.totalVolume], ...(metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt] !== undefined diff --git a/src/modules/indexer/trade-indexer.integration.test.ts b/src/modules/indexer/trade-indexer.integration.test.ts new file mode 100644 index 0000000..51f1e69 --- /dev/null +++ b/src/modules/indexer/trade-indexer.integration.test.ts @@ -0,0 +1,123 @@ +import { processTradeEvent, SorobanBuyEvent } from './trade-indexer.service'; +import { logger } from '../../utils/logger.utils'; + +function makeMockDb() { + const store = new Map(); + + return { + store, + trade: { + findUnique: jest.fn(async ({ where }: { where: { ledger_txHash: { ledger: number; txHash: string } } }) => { + const key = `${where.ledger_txHash.ledger}:${where.ledger_txHash.txHash}`; + return store.has(key) ? store.get(key) : null; + }), + create: jest.fn(async ({ data }: { data: any }) => { + const key = `${data.ledger}:${data.txHash}`; + const record = { + id: `trade-${store.size + 1}`, + ...data, + }; + store.set(key, record); + return record; + }), + findMany: jest.fn(async () => Array.from(store.values())), + }, + }; +} + +describe('#619 Trade indexer — persisting Soroban buy events', () => { + let mockDb: ReturnType; + let loggerWarnSpy: jest.SpyInstance; + + beforeEach(() => { + mockDb = makeMockDb(); + loggerWarnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const validBuyEvent: SorobanBuyEvent = { + buyer: 'GBUYER11111111111111111111111111111111111111111111111111', + creator_id: 'creator-xyz', + quantity: '10', + price: '5000', + ledger: 1001, + tx_hash: '0xabc123hash', + timestamp: '2026-07-25T12:00:00.000Z', + }; + + it('creates a trade record with all six fields correct', async () => { + const processed = await processTradeEvent(validBuyEvent, mockDb as any); + + expect(processed).toBe(true); + expect(mockDb.trade.create).toHaveBeenCalledTimes(1); + + const records = await mockDb.trade.findMany(); + expect(records).toHaveLength(1); + + const record = records[0]; + expect(record.buyer).toBe('GBUYER11111111111111111111111111111111111111111111111111'); + expect(record.creatorId).toBe('creator-xyz'); + expect(record.quantity).toBe('10'); + expect(record.price).toBe('5000'); + expect(record.ledger).toBe(1001); + expect(record.timestamp).toEqual(new Date('2026-07-25T12:00:00.000Z')); + }); + + it('is idempotent — duplicate event does not create a second record', async () => { + const firstRun = await processTradeEvent(validBuyEvent, mockDb as any); + expect(firstRun).toBe(true); + + const secondRun = await processTradeEvent(validBuyEvent, mockDb as any); + expect(secondRun).toBe(false); + + expect(mockDb.trade.create).toHaveBeenCalledTimes(1); + const records = await mockDb.trade.findMany(); + expect(records).toHaveLength(1); + }); + + it('skips a malformed event missing a required field without crashing', async () => { + const malformedEvent: Partial = { + buyer: 'GBUYER11111111111111111111111111111111111111111111111111', + // creator_id missing! + quantity: '10', + price: '5000', + ledger: 1002, + tx_hash: '0xdef456hash', + timestamp: '2026-07-25T12:00:00.000Z', + }; + + const result = await processTradeEvent(malformedEvent, mockDb as any); + + expect(result).toBe(false); + expect(mockDb.trade.create).not.toHaveBeenCalled(); + + const records = await mockDb.trade.findMany(); + expect(records).toHaveLength(0); + }); + + it('produces a warn-level log when an event is skipped', async () => { + const malformedEvent: Partial = { + buyer: 'GBUYER11111111111111111111111111111111111111111111111111', + creator_id: 'creator-xyz', + // price missing! + quantity: '10', + ledger: 1003, + tx_hash: '0x789hash', + timestamp: '2026-07-25T12:00:00.000Z', + }; + + await processTradeEvent(malformedEvent, mockDb as any); + + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'trade_event_skipped', + missing_fields: ['price'], + }), + 'Skipping trade event with missing required fields' + ); + }); +}); diff --git a/src/modules/indexer/trade-indexer.service.ts b/src/modules/indexer/trade-indexer.service.ts new file mode 100644 index 0000000..a0b7210 --- /dev/null +++ b/src/modules/indexer/trade-indexer.service.ts @@ -0,0 +1,91 @@ +import { logger } from '../../utils/logger.utils'; +import { buildLogFields } from '../../utils/log-fields.utils'; + +export interface SorobanBuyEvent { + buyer: string; + creator_id: string; + quantity: string; + price: string; + ledger: number; + tx_hash: string; + timestamp: string; +} + +const REQUIRED_FIELDS: (keyof SorobanBuyEvent)[] = [ + 'buyer', + 'creator_id', + 'quantity', + 'price', + 'ledger', + 'tx_hash', + 'timestamp', +]; + +function validateEvent( + event: Partial +): event is SorobanBuyEvent { + for (const field of REQUIRED_FIELDS) { + const value = event[field]; + if (value === undefined || value === null || value === '') { + return false; + } + } + + if (typeof event.ledger !== 'number' || isNaN(event.ledger)) { + return false; + } + + return true; +} + +export async function processTradeEvent( + event: Partial, + db: any +): Promise { + if (!validateEvent(event)) { + const missing = REQUIRED_FIELDS.filter(f => { + const v = event[f]; + return v === undefined || v === null || v === ''; + }); + + logger.warn( + buildLogFields({ + type: 'trade_event_skipped', + missing_fields: missing, + ledger: event.ledger, + tx_hash: event.tx_hash, + }), + 'Skipping trade event with missing required fields' + ); + + return false; + } + + const existing = await db.trade.findUnique({ + where: { + ledger_txHash: { + ledger: event.ledger, + txHash: event.tx_hash, + }, + }, + select: { id: true }, + }); + + if (existing) { + return false; + } + + await db.trade.create({ + data: { + buyer: event.buyer, + creatorId: event.creator_id, + quantity: event.quantity, + price: event.price, + ledger: event.ledger, + txHash: event.tx_hash, + timestamp: new Date(event.timestamp), + }, + }); + + return true; +} diff --git a/src/utils/__tests__/log-fields.utils.test.ts b/src/utils/__tests__/log-fields.utils.test.ts new file mode 100644 index 0000000..d3d4b0d --- /dev/null +++ b/src/utils/__tests__/log-fields.utils.test.ts @@ -0,0 +1,85 @@ +import { buildLogFields } from '../log-fields.utils'; + +describe('#631 buildLogFields — structured log formatting helper', () => { + it('converts camelCase keys to snake_case', () => { + const input = { + creatorId: 'creator-123', + ledgerSequence: 98765, + actorAddress: 'GABC123', + elapsedMs: 42, + }; + + const result = buildLogFields(input); + + expect(result).toEqual({ + creator_id: 'creator-123', + ledger_sequence: 98765, + actor_address: 'GABC123', + elapsed_ms: 42, + }); + }); + + it('formats timestamp Date fields as ISO 8601 strings', () => { + const date = new Date('2026-07-25T14:30:00.000Z'); + const input = { + createdAt: date, + processedAt: date, + }; + + const result = buildLogFields(input); + + expect(result).toEqual({ + created_at: '2026-07-25T14:30:00.000Z', + processed_at: '2026-07-25T14:30:00.000Z', + }); + }); + + it('truncates long strings exceeding 500 characters with a [TRUNCATED] suffix', () => { + const longString = 'a'.repeat(600); + const input = { + payload: longString, + }; + + const result = buildLogFields(input); + const outputPayload = result.payload as string; + + expect(outputPayload).toHaveLength(500 + '[TRUNCATED]'.length); + expect(outputPayload.slice(0, 500)).toBe('a'.repeat(500)); + expect(outputPayload.endsWith('[TRUNCATED]')).toBe(true); + }); + + it('leaves short strings at or under 500 characters unchanged', () => { + const exact500String = 'b'.repeat(500); + const shortString = 'Hello World'; + + const input = { + exactMsg: exact500String, + shortMsg: shortString, + }; + + const result = buildLogFields(input); + + expect(result.exact_msg).toBe(exact500String); + expect(result.short_msg).toBe(shortString); + }); + + it('handles nested objects and arrays correctly', () => { + const input = { + userInfo: { + userHandle: 'alice', + lastLogin: new Date('2026-01-01T00:00:00.000Z'), + }, + itemTags: ['tagOne', 'tagTwo'], + }; + + const result = buildLogFields(input); + + expect(result).toEqual({ + user_info: { + user_handle: 'alice', + last_login: '2026-01-01T00:00:00.000Z', + }, + item_tags: ['tagOne', 'tagTwo'], + }); + }); +}); diff --git a/src/utils/__tests__/trade-pagination.utils.test.ts b/src/utils/__tests__/trade-pagination.utils.test.ts new file mode 100644 index 0000000..95e5cac --- /dev/null +++ b/src/utils/__tests__/trade-pagination.utils.test.ts @@ -0,0 +1,101 @@ +import { queryTradesPage, TradeRecord } from '../trade-pagination.utils'; + +function makeTrade(id: string, ledger: number, txHash: string, creatorId = 'creator-1'): TradeRecord { + return { + id, + buyer: 'GBUYER123', + creatorId, + quantity: '10', + price: '100', + ledger, + txHash, + timestamp: new Date(), + }; +} + +function makeMockDb(allTrades: TradeRecord[]) { + return { + trade: { + findMany: jest.fn(async ({ where, _orderBy, take }: { where: any; _orderBy?: any[]; take: number }) => { + let filtered = allTrades.filter(t => t.creatorId === where.creatorId); + + if (where.OR) { + const [ltLedger, eqLedgerLtHash] = where.OR; + filtered = filtered.filter(t => { + if (t.ledger < ltLedger.ledger.lt) return true; + if (t.ledger === eqLedgerLtHash.ledger && t.txHash < eqLedgerLtHash.txHash.lt) return true; + return false; + }); + } + + // Order by ledger desc, txHash desc + filtered.sort((a, b) => { + if (b.ledger !== a.ledger) return b.ledger - a.ledger; + return b.txHash.localeCompare(a.txHash); + }); + + return filtered.slice(0, take); + }), + }, + }; +} + +describe('#625 queryTradesPage — keyset cursor pagination helper', () => { + const trades: TradeRecord[] = [ + makeTrade('t5', 500, '0x05'), + makeTrade('t4', 400, '0x04'), + makeTrade('t3', 300, '0x03'), + makeTrade('t2', 200, '0x02'), + makeTrade('t1', 100, '0x01'), + ]; + + it('first page (null cursor) returns limit results ordered by ledger desc', async () => { + const db = makeMockDb(trades); + const res = await queryTradesPage('creator-1', null, 2, db); + + expect(res.items).toHaveLength(2); + expect(res.items[0].id).toBe('t5'); + expect(res.items[1].id).toBe('t4'); + expect(res.has_more).toBe(true); + expect(res.cursor).not.toBeNull(); + }); + + it('cursor page returns results strictly after the cursor position', async () => { + const db = makeMockDb(trades); + + // Fetch page 1 + const page1 = await queryTradesPage('creator-1', null, 2, db); + expect(page1.items.map(i => i.id)).toEqual(['t5', 't4']); + + // Fetch page 2 using cursor from page 1 + const page2 = await queryTradesPage('creator-1', page1.cursor, 2, db); + expect(page2.items.map(i => i.id)).toEqual(['t3', 't2']); + expect(page2.has_more).toBe(true); + }); + + it('last page returns has_more: false and correct remaining results', async () => { + const db = makeMockDb(trades); + + const page1 = await queryTradesPage('creator-1', null, 2, db); + const page2 = await queryTradesPage('creator-1', page1.cursor, 2, db); + const page3 = await queryTradesPage('creator-1', page2.cursor, 2, db); + + expect(page3.items.map(i => i.id)).toEqual(['t1']); + expect(page3.has_more).toBe(false); + expect(page3.cursor).toBeNull(); + }); + + it('same record never appears across two consecutive pages', async () => { + const db = makeMockDb(trades); + + const page1 = await queryTradesPage('creator-1', null, 2, db); + const page2 = await queryTradesPage('creator-1', page1.cursor, 2, db); + + const page1Ids = new Set(page1.items.map(i => i.id)); + const page2Ids = page2.items.map(i => i.id); + + for (const id of page2Ids) { + expect(page1Ids.has(id)).toBe(false); + } + }); +}); diff --git a/src/utils/indexer-event-processor.utils.ts b/src/utils/indexer-event-processor.utils.ts index 8c87ede..b9eb8cf 100644 --- a/src/utils/indexer-event-processor.utils.ts +++ b/src/utils/indexer-event-processor.utils.ts @@ -1,4 +1,5 @@ import { logger } from './logger.utils'; +import { buildLogFields } from './log-fields.utils'; import { dedupeChainEvents, ChainEvent } from './indexer-dedupe.utils'; import { elapsedMs, startTimer } from './monotonic-clock.utils'; @@ -34,7 +35,7 @@ export async function processIndexerChainEvent( await handler(event); logger.info( - { + buildLogFields({ type: 'indexer_event_processed', eventType: event.eventType, eventId, @@ -42,7 +43,7 @@ export async function processIndexerChainEvent( eventIndex: event.eventIndex, ledger: event.ledger, elapsedMs: elapsedMs(timer), - }, + }), 'Indexer chain event processed' ); } diff --git a/src/utils/indexer-trade-event-logger.utils.ts b/src/utils/indexer-trade-event-logger.utils.ts index 8d9ad19..5d79424 100644 --- a/src/utils/indexer-trade-event-logger.utils.ts +++ b/src/utils/indexer-trade-event-logger.utils.ts @@ -1,4 +1,5 @@ import { logger } from './logger.utils'; +import { buildLogFields } from './log-fields.utils'; export interface IndexerTradeEventLogFields { event_type: 'buy' | 'sell'; @@ -20,15 +21,15 @@ function maskActorAddress(address: string): string { */ export function logIndexerTradeEvent(fields: IndexerTradeEventLogFields): void { logger.info( - { + buildLogFields({ type: 'indexer_trade_processed', event_type: fields.event_type, creator_id: fields.creator_id, ledger_sequence: fields.ledger_sequence, actor_address: maskActorAddress(fields.actor_address), amount: fields.amount, - processed_at: fields.processed_at.toISOString(), - }, + processed_at: fields.processed_at, + }), 'Indexer trade event processed' ); } diff --git a/src/utils/log-fields.utils.ts b/src/utils/log-fields.utils.ts new file mode 100644 index 0000000..9930779 --- /dev/null +++ b/src/utils/log-fields.utils.ts @@ -0,0 +1,76 @@ +export type LogFields = Record; + +const TRUNCATE_LIMIT = 500; +const TRUNCATE_SUFFIX = '[TRUNCATED]'; + +/** + * Converts a string from camelCase or PascalCase to snake_case. + */ +export function toSnakeCase(key: string): string { + return key + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); +} + +/** + * Formats a value for structured logging according to repository standards: + * - Date instances are converted to ISO 8601 strings. + * - Strings exceeding 500 characters are truncated with '[TRUNCATED]' suffix. + * - Nested objects and arrays are recursively processed. + */ +export function formatLogFieldValue(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === 'string') { + if (value.length > TRUNCATE_LIMIT) { + return `${value.slice(0, TRUNCATE_LIMIT)}${TRUNCATE_SUFFIX}`; + } + return value; + } + + if (Array.isArray(value)) { + return value.map(item => formatLogFieldValue(item)); + } + + if (typeof value === 'object') { + const formattedObj: Record = {}; + for (const [key, val] of Object.entries(value)) { + formattedObj[toSnakeCase(key)] = formatLogFieldValue(val); + } + return formattedObj; + } + + return value; +} + +/** + * Helper for formatting structured log fields consistently across all server modules. + * + * Enforces: + * - snake_case key naming across all fields + * - ISO 8601 timestamp string formatting for dates + * - Truncation of any string value > 500 characters with a [TRUNCATED] suffix + * + * @param base - Raw log fields object + * @returns Standardised log fields record ready for logger emission + */ +export function buildLogFields(base: LogFields): Record { + if (!base || typeof base !== 'object') { + return {}; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(base)) { + const snakeKey = toSnakeCase(key); + result[snakeKey] = formatLogFieldValue(value); + } + + return result; +} diff --git a/src/utils/trade-pagination.utils.ts b/src/utils/trade-pagination.utils.ts new file mode 100644 index 0000000..aa08e15 --- /dev/null +++ b/src/utils/trade-pagination.utils.ts @@ -0,0 +1,80 @@ +import { encodeCursor, decodeCursor } from './cursor.utils'; + +export interface TradeRecord { + id: string; + buyer: string; + creatorId: string; + quantity: string; + price: string; + ledger: number; + txHash: string; + timestamp: Date; +} + +export interface TradeCursorPayload { + ledger: number; + tx_hash: string; +} + +export interface PaginatedTradesResult { + items: TradeRecord[]; + cursor: string | null; + has_more: boolean; +} + +/** + * Keyset cursor helper for standardising pagination across all trade history queries. + * + * @param creatorId - The target creator's ID + * @param cursor - Encoded (ledger, tx_hash) cursor or null for first page + * @param limit - Page size limit + * @param db - Prisma client instance + */ +export async function queryTradesPage( + creatorId: string, + cursor: string | null, + limit: number, + db: any +): Promise { + const take = Math.max(1, limit); + + let whereClause: any = { creatorId }; + + if (cursor) { + const decoded = decodeCursor(cursor); + whereClause = { + creatorId, + OR: [ + { ledger: { lt: decoded.ledger } }, + { + ledger: decoded.ledger, + txHash: { lt: decoded.tx_hash }, + }, + ], + }; + } + + const records: TradeRecord[] = await db.trade.findMany({ + where: whereClause, + orderBy: [{ ledger: 'desc' }, { txHash: 'desc' }], + take: take + 1, + }); + + const has_more = records.length > take; + const items = has_more ? records.slice(0, take) : records; + + let nextCursor: string | null = null; + if (items.length > 0 && has_more) { + const lastItem = items[items.length - 1]; + nextCursor = encodeCursor({ + ledger: lastItem.ledger, + tx_hash: lastItem.txHash, + }); + } + + return { + items, + cursor: nextCursor, + has_more, + }; +}