diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index d0737aa..1c42fe1 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -24,10 +24,12 @@ const streamStoreMocks = vi.hoisted(() => ({ const eventHistoryMocks = vi.hoisted(() => ({ getStreamHistory: vi.fn(), + getStreamClaimEvents: vi.fn(), getAllEvents: vi.fn(), getGlobalEvents: vi.fn(), countAllEvents: vi.fn(), countStreamEvents: vi.fn(), + countStreamClaimEvents: vi.fn(), recordEvent: vi.fn(), getStreamEventSummary: vi.fn(), })); @@ -233,7 +235,9 @@ beforeEach(() => { eventHistoryMocks.getGlobalEvents.mockReset(); eventHistoryMocks.countAllEvents.mockReset(); eventHistoryMocks.countStreamEvents.mockReset(); + eventHistoryMocks.countStreamClaimEvents.mockReset(); eventHistoryMocks.getStreamHistory.mockReset(); + eventHistoryMocks.getStreamClaimEvents.mockReset(); eventHistoryMocks.getStreamEventSummary.mockReset(); eventHistoryMocks.recordEvent.mockReset(); }); @@ -784,3 +788,60 @@ describe("GET /api/events", () => { }); }); +describe("GET /api/streams/:id/claim-history", () => { + it("returns 404 for non-existent stream", async () => { + streamStoreMocks.getStream.mockReturnValue(null); + + const response = await request(app).get("/api/streams/nonexistent/claim-history"); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Stream not found."); + }); + + it("returns paginated claim events with tx_hash and Stellar Explorer links", async () => { + const mockStream = { + id: "stream-123", + sender: "GSENDER...", + recipient: "GRECIPIENT...", + totalAmount: 1000, + durationSeconds: 3600, + startAt: 1000, + createdAt: 1000, + }; + streamStoreMocks.getStream.mockReturnValue(mockStream); + eventHistoryMocks.countStreamClaimEvents.mockReturnValue(1); + eventHistoryMocks.getStreamClaimEvents.mockReturnValue([ + { + id: 1, + streamId: "stream-123", + eventType: "claimed", + timestamp: 1500, + actor: "GRECIPIENT...", + amount: 250, + metadata: { tx_hash: "abcd1234txhash" }, + }, + ]); + + const response = await request(app).get("/api/streams/stream-123/claim-history"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [ + { + claim_id: 1, + amount: 250, + claimed_at: 1500, + tx_hash: "abcd1234txhash", + recipient: "GRECIPIENT...", + explorer_url: "https://stellar.expert/explorer/testnet/tx/abcd1234txhash", + stellar_explorer_url: "https://stellar.expert/explorer/testnet/tx/abcd1234txhash", + }, + ], + total: 1, + page: 1, + pageSize: 20, + hasMore: false, + }); + }); +}); + diff --git a/backend/src/index.ts b/backend/src/index.ts index 4ff0cfd..c69333d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,7 +22,9 @@ import { getAllEvents, getGlobalEvents, getStreamHistory, + getStreamClaimEvents, countStreamEvents, + countStreamClaimEvents, getStreamEventSummary, StreamEventType, } from "./services/eventHistory"; @@ -1711,6 +1713,58 @@ app.get( }, ); +app.get( + "/api/streams/:id/claim-history", + readLimiter, + (req: Request, res: Response) => { + const parsedId = parseStreamId(req.params.id); + if (!parsedId.ok) { + sendValidationError(req, res, parsedId.issues); + return; + } + + const stream = getStream(parsedId.value); + if (!stream) { + sendApiError(req, res, 404, "Stream not found.", { code: "NOT_FOUND" }); + return; + } + + // Parse and validate query parameters + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min( + Math.max(1, parseInt(req.query.pageSize as string) || 20), + 100, + ); + + const total = countStreamClaimEvents(parsedId.value); + const offset = (page - 1) * pageSize; + const events = getStreamClaimEvents(parsedId.value, pageSize, offset); + const hasMore = offset + pageSize < total; + + const data = events.map((event) => { + const txHash = + event.metadata?.tx_hash ?? + event.metadata?.txHash ?? + ""; + const explorerUrl = txHash + ? `https://stellar.expert/explorer/testnet/tx/${txHash}` + : null; + + return { + claim_id: event.id, + amount: event.amount ?? 0, + claimed_at: event.timestamp, + tx_hash: txHash, + recipient: event.actor ?? stream.recipient, + explorer_url: explorerUrl, + stellar_explorer_url: explorerUrl, + }; + }); + + res.json({ data, total, page, pageSize, hasMore }); + }, +); + app.get( "/api/streams/:id/history/summary", readLimiter, diff --git a/backend/src/services/eventHistory.test.ts b/backend/src/services/eventHistory.test.ts index 81358ea..eff76ff 100644 --- a/backend/src/services/eventHistory.test.ts +++ b/backend/src/services/eventHistory.test.ts @@ -188,4 +188,24 @@ describe("eventHistory", () => { expect(historyB[0].timestamp).toBe(500); }); }); + + describe("getStreamClaimEvents & countStreamClaimEvents", () => { + it("returns only claim events for a stream and counts them correctly", async () => { + const { recordEvent, getStreamClaimEvents, countStreamClaimEvents } = await import("./eventHistory"); + + recordEvent("stream-claim-test", "created", 1000, "SENDER", 500); + recordEvent("stream-claim-test", "claimed", 2000, "RECIPIENT", 100, { tx_hash: "0xhash1" }); + recordEvent("stream-claim-test", "paused", 2500, "SENDER"); + recordEvent("stream-claim-test", "claimed", 3000, "RECIPIENT", 150, { tx_hash: "0xhash2" }); + + const claimEvents = getStreamClaimEvents("stream-claim-test", 20, 0, "desc"); + const count = countStreamClaimEvents("stream-claim-test"); + + expect(count).toBe(2); + expect(claimEvents).toHaveLength(2); + expect(claimEvents.every((e) => e.eventType === "claimed")).toBe(true); + expect(claimEvents[0].amount).toBe(150); + expect(claimEvents[1].amount).toBe(100); + }); + }); }); diff --git a/backend/src/services/eventHistory.ts b/backend/src/services/eventHistory.ts index 0dc429b..bff5114 100644 --- a/backend/src/services/eventHistory.ts +++ b/backend/src/services/eventHistory.ts @@ -90,6 +90,17 @@ export function getStreamHistory(streamId: string, limit = 20, offset = 0, order return rows.map(rowToEvent); } +export function getStreamClaimEvents(streamId: string, limit = 20, offset = 0, order: 'asc' | 'desc' = 'desc'): StreamEvent[] { + const db = getDb(); + const orderClause = order === 'asc' ? 'ASC' : 'DESC'; + const rows = db + .prepare( + `SELECT * FROM stream_events WHERE stream_id = ? AND event_type = 'claimed' ORDER BY timestamp ${orderClause}, id ${orderClause} LIMIT ? OFFSET ?`, + ) + .all(streamId, limit, offset) as EventRow[]; + return rows.map(rowToEvent); +} + export function getAllEvents(limit = 100, offset = 0, cursor?: number): StreamEvent[] { const db = getDb(); let query = `SELECT * FROM stream_events`; @@ -191,6 +202,14 @@ export function countStreamEvents(streamId: string): number { return row.count; } +export function countStreamClaimEvents(streamId: string): number { + const db = getDb(); + const row = db + .prepare(`SELECT COUNT(*) as count FROM stream_events WHERE stream_id = ? AND event_type = 'claimed'`) + .get(streamId) as { count: number }; + return row.count; +} + export interface StreamEventSummary { totalEvents: number; byType: Partial>; diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index 85c9487..56c3d19 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -439,7 +439,10 @@ function processEvent(db: any, event: rpc.Api.EventResponse): void { // actor == recipient for Claimed events value.actor ?? value.recipient, value.amount, - { claimed_amount: value.claimed_amount }, + { + claimed_amount: value.claimed_amount, + tx_hash: (event as any).txHash ?? (event as any).tx_hash ?? (event as any).transactionHash, + }, event.ledger, ); break; diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 13928a0..4969394 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1612,6 +1612,87 @@ export const swaggerDocument = { }, }, }, + "/api/streams/{id}/claim-history": { + get: { + summary: "Get stream claim history", + description: + "Retrieves dedicated claim history for a specific stream. Returns only claim events with amount, timestamp, tx_hash, recipient, and Stellar Explorer link.", + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "The unique ID of the stream.", + schema: { + type: "string", + }, + }, + { + name: "page", + in: "query", + required: false, + description: "Page number for pagination (default: 1)", + schema: { + type: "integer", + default: 1, + }, + }, + { + name: "pageSize", + in: "query", + required: false, + description: "Number of items per page (default: 20, max: 100)", + schema: { + type: "integer", + default: 20, + }, + }, + ], + responses: { + "200": { + description: "Stream claim history.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + claim_id: { type: "integer" }, + amount: { type: "number" }, + claimed_at: { type: "integer" }, + tx_hash: { type: "string" }, + recipient: { type: "string" }, + explorer_url: { type: "string", nullable: true }, + stellar_explorer_url: { type: "string", nullable: true }, + }, + }, + }, + total: { type: "integer" }, + page: { type: "integer" }, + pageSize: { type: "integer" }, + hasMore: { type: "boolean" }, + }, + }, + }, + }, + }, + "404": { + description: "Stream not found.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + }, + }, + }, + }, + }, + }, "/api/streams/{id}/history/summary": { get: { summary: "Get stream event count summary",