Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions backend/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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,
});
});
});

54 changes: 54 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
getAllEvents,
getGlobalEvents,
getStreamHistory,
getStreamClaimEvents,
countStreamEvents,
countStreamClaimEvents,
getStreamEventSummary,
StreamEventType,
} from "./services/eventHistory";
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/services/eventHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
19 changes: 19 additions & 0 deletions backend/src/services/eventHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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<Record<StreamEventType, number>>;
Expand Down
5 changes: 4 additions & 1 deletion backend/src/services/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions backend/src/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down