diff --git a/src/index.ts b/src/index.ts index 979e953d..be3ce078 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { predictionsRouter } from "./routes/predictions"; import { usersRouter } from "./routes/users"; import { usersHealthRouter } from "./routes/users/health"; import { userPortfolioRouter } from "./routes/users/portfolio"; +import { userStatsRouter } from "./routes/users/stats"; import { devicesRouter } from "./routes/devices"; import { adminFeatureFlagsRouter } from "./routes/admin/featureFlags"; import { adminUsersRouter } from "./routes/adminUsers"; @@ -163,6 +164,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { app.use("/api/users/health", usersHealthRouter); app.use("/api/users", socialRouter); app.use("/api/users", userPortfolioRouter); + app.use("/api/users", userStatsRouter); app.use("/api/users", usersRouter); app.use("/api/me/devices", devicesRouter); app.use("/api/me/sessions", sessionsRouter); diff --git a/src/routes/users/stats.ts b/src/routes/users/stats.ts new file mode 100644 index 00000000..843451f5 --- /dev/null +++ b/src/routes/users/stats.ts @@ -0,0 +1,24 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { getUserStats } from "../../services/userStatsService"; + +export const userStatsRouter = Router(); + +const stellarAddressSchema = z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address"); + +userStatsRouter.get("/:addr/stats", async (req: Request, res: Response, next: NextFunction) => { + const parsed = stellarAddressSchema.safeParse(req.params.addr); + if (!parsed.success) { + return res.status(400).json({ error: { code: "invalid_address" } }); + } + + try { + const stats = await getUserStats(parsed.data); + if (!stats) { + return res.status(404).json({ error: { code: "not_found" } }); + } + return res.json({ data: stats }); + } catch (error) { + return next(error); + } +}); diff --git a/src/services/userStatsService.ts b/src/services/userStatsService.ts new file mode 100644 index 00000000..289f665d --- /dev/null +++ b/src/services/userStatsService.ts @@ -0,0 +1,111 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "../db/client"; +import { claims, predictions, users } from "../db/schema"; +import { getRequestId } from "../lib/requestContext"; +import { logger } from "../config/logger"; + +const CACHE_TTL_MS = 30_000; + +type CacheEntry = { expiresAt: number; value: UserStats }; +const cache = new Map(); + +export interface UserStats { + address: string; + totalPredictions: number; + totalStaked: string; + marketsParticipated: number; + byStatus: { + won: number; + lost: number; + pending: number; + confirmed: number; + claimed: number; + }; + totalClaimed: string; + winRate: number; + cachedAt: string; +} + +function addDecimalStrings(a: string, b: string): string { + if (!a && !b) return "0"; + if (!a || !/^\d+$/.test(a)) return b && /^\d+$/.test(b) ? b : "0"; + if (!b || !/^\d+$/.test(b)) return a; + return (BigInt(a) + BigInt(b)).toString(); +} + +export function clearUserStatsCache(): void { + cache.clear(); +} + +export async function getUserStats(address: string): Promise { + const now = Date.now(); + const cached = cache.get(address); + if (cached && cached.expiresAt > now) return cached.value; + + const db = getDb(); + const userRows = await db + .select({ id: users.id, stellarAddress: users.stellarAddress }) + .from(users) + .where(eq(users.stellarAddress, address)) + .limit(1); + const user = userRows[0]; + if (!user) return null; + + const [predictionRows, claimRows] = await Promise.all([ + db + .select({ + id: predictions.id, + marketId: predictions.marketId, + amount: predictions.amount, + status: predictions.status, + }) + .from(predictions) + .where(eq(predictions.userId, user.id)), + db + .select({ amount: claims.amount }) + .from(claims) + .where(eq(claims.userId, user.id)), + ]); + + const byStatus = { won: 0, lost: 0, pending: 0, confirmed: 0, claimed: 0 }; + let totalStaked = "0"; + const markets = new Set(); + + for (const row of predictionRows) { + totalStaked = addDecimalStrings(totalStaked, row.amount); + markets.add(row.marketId); + + const key = row.status as keyof typeof byStatus; + if (key in byStatus) { + byStatus[key] += 1; + } + } + + let totalClaimed = "0"; + for (const row of claimRows) { + totalClaimed = addDecimalStrings(totalClaimed, row.amount); + } + + const resolvedTotal = byStatus.won + byStatus.lost; + const winRate = resolvedTotal > 0 ? byStatus.won / resolvedTotal : 0; + + const stats: UserStats = { + address: user.stellarAddress, + totalPredictions: predictionRows.length, + totalStaked, + marketsParticipated: markets.size, + byStatus, + totalClaimed, + winRate, + cachedAt: new Date(now).toISOString(), + }; + + cache.set(address, { value: stats, expiresAt: now + CACHE_TTL_MS }); + + logger.info( + { reqId: getRequestId(), address, totalPredictions: predictionRows.length, winRate }, + "Computed user stats", + ); + + return stats; +} diff --git a/tests/userStats.test.ts b/tests/userStats.test.ts new file mode 100644 index 00000000..77e6a258 --- /dev/null +++ b/tests/userStats.test.ts @@ -0,0 +1,73 @@ +process.env.NODE_ENV = "test"; +process.env.LOG_LEVEL = "fatal"; +process.env.DATABASE_URL = "postgres://localhost/test"; +process.env.JWT_SECRET = "stats-test-secret-at-least-32-bytes"; +process.env.STELLAR_NETWORK = "testnet"; +process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; +process.env.HORIZON_URL = "https://horizon-testnet.stellar.org"; +process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF"; + +jest.mock("ioredis", () => jest.fn().mockImplementation(() => ({ on: jest.fn(), ping: jest.fn() }))); +jest.mock("bullmq", () => ({ Queue: jest.fn().mockImplementation(() => ({ on: jest.fn(), add: jest.fn(), close: jest.fn() })) })); + +import request from "supertest"; +import { createApp } from "../src/index"; +import { getUserStats } from "../src/services/userStatsService"; + +jest.mock("../src/services/userStatsService", () => ({ + getUserStats: jest.fn(), +})); + +const mockGetUserStats = getUserStats as jest.MockedFunction; +const ADDRESS = `G${"A".repeat(55)}`; + +describe("GET /api/users/:addr/stats", () => { + beforeEach(() => { + mockGetUserStats.mockReset(); + }); + + it("returns per-user aggregated stats", async () => { + mockGetUserStats.mockResolvedValue({ + address: ADDRESS, + totalPredictions: 42, + totalStaked: "15000", + marketsParticipated: 8, + byStatus: { won: 12, lost: 5, pending: 20, confirmed: 3, claimed: 2 }, + totalClaimed: "5000", + winRate: 0.71, + cachedAt: "2026-07-27T00:00:00.000Z", + }); + + const res = await request(createApp()).get(`/api/users/${ADDRESS}/stats`); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual( + expect.objectContaining({ + address: ADDRESS, + totalPredictions: 42, + totalStaked: "15000", + marketsParticipated: 8, + winRate: 0.71, + }), + ); + expect(res.body.data.byStatus).toEqual({ won: 12, lost: 5, pending: 20, confirmed: 3, claimed: 2 }); + expect(mockGetUserStats).toHaveBeenCalledWith(ADDRESS); + }); + + it("rejects invalid addresses", async () => { + const res = await request(createApp()).get("/api/users/not-a-wallet/stats"); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: { code: "invalid_address" } }); + expect(mockGetUserStats).not.toHaveBeenCalled(); + }); + + it("returns 404 for unknown users", async () => { + mockGetUserStats.mockResolvedValue(null); + + const res = await request(createApp()).get(`/api/users/${ADDRESS}/stats`); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: { code: "not_found" } }); + }); +}); diff --git a/tests/userStatsService.test.ts b/tests/userStatsService.test.ts new file mode 100644 index 00000000..6033714f --- /dev/null +++ b/tests/userStatsService.test.ts @@ -0,0 +1,102 @@ +import { getUserStats, clearUserStatsCache } from "../src/services/userStatsService"; + +const select = jest.fn(); + +jest.mock("../src/db/client", () => ({ + getDb: () => ({ select }), +})); + +const ADDRESS = `G${"A".repeat(55)}`; + +function selectReturning(rows: unknown[], needsLimit = false) { + return { + from: jest.fn(() => ({ + where: jest.fn(() => + needsLimit + ? { limit: jest.fn(async () => rows) } + : Promise.resolve(rows), + ), + })), + }; +} + +describe("getUserStats", () => { + beforeEach(() => { + clearUserStatsCache(); + select.mockReset(); + }); + + it("aggregates predictions by status and computes win rate", async () => { + select + .mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true)) + .mockReturnValueOnce(selectReturning([ + { id: "p1", marketId: "mkt-1", amount: "100", status: "won" }, + { id: "p2", marketId: "mkt-1", amount: "50", status: "lost" }, + { id: "p3", marketId: "mkt-2", amount: "25", status: "pending" }, + { id: "p4", marketId: "mkt-3", amount: "75", status: "confirmed" }, + { id: "p5", marketId: "mkt-3", amount: "10", status: "claimed" }, + ])) + .mockReturnValueOnce(selectReturning([{ amount: "200" }])); + + const stats = await getUserStats(ADDRESS); + + expect(stats).not.toBeNull(); + expect(stats!.totalPredictions).toBe(5); + expect(stats!.totalStaked).toBe("260"); + expect(stats!.marketsParticipated).toBe(3); + expect(stats!.totalClaimed).toBe("200"); + expect(stats!.byStatus).toEqual({ won: 1, lost: 1, pending: 1, confirmed: 1, claimed: 1 }); + expect(stats!.winRate).toBe(0.5); + }); + + it("returns winRate 0 when no resolved predictions", async () => { + select + .mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true)) + .mockReturnValueOnce(selectReturning([ + { id: "p1", marketId: "mkt-1", amount: "10", status: "pending" }, + { id: "p2", marketId: "mkt-1", amount: "20", status: "pending" }, + ])) + .mockReturnValueOnce(selectReturning([])); + + const stats = await getUserStats(ADDRESS); + + expect(stats!.winRate).toBe(0); + expect(stats!.totalStaked).toBe("30"); + }); + + it("returns winRate 1 when all won", async () => { + select + .mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true)) + .mockReturnValueOnce(selectReturning([ + { id: "p1", marketId: "mkt-1", amount: "100", status: "won" }, + { id: "p2", marketId: "mkt-2", amount: "50", status: "won" }, + ])) + .mockReturnValueOnce(selectReturning([])); + + const stats = await getUserStats(ADDRESS); + + expect(stats!.winRate).toBe(1); + expect(stats!.byStatus).toEqual({ won: 2, lost: 0, pending: 0, confirmed: 0, claimed: 0 }); + }); + + it("returns null for unknown address", async () => { + select + .mockReturnValueOnce(selectReturning([], true)); + + const stats = await getUserStats(ADDRESS); + + expect(stats).toBeNull(); + }); + + it("uses the short-lived cache for repeated reads", async () => { + select + .mockReturnValueOnce(selectReturning([{ id: "user-1", stellarAddress: ADDRESS }], true)) + .mockReturnValueOnce(selectReturning([])) + .mockReturnValueOnce(selectReturning([])); + + await getUserStats(ADDRESS); + await getUserStats(ADDRESS); + + expect(select).toHaveBeenCalledTimes(3); + }); +});