diff --git a/apps/api/src/routes/users.earnings.test.ts b/apps/api/src/routes/users.earnings.test.ts new file mode 100644 index 0000000..c50aa39 --- /dev/null +++ b/apps/api/src/routes/users.earnings.test.ts @@ -0,0 +1,208 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/error"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + findUserById: vi.fn(), +})); + +vi.mock("../db", () => ({ + query: mocks.query, +})); + +vi.mock("../db/queries/users", () => ({ + findUserById: mocks.findUserById, + findUserByPhoneHash: vi.fn(), + markPhoneVerified: vi.fn(), + updateUserWallet: vi.fn(), + updateUserProfile: vi.fn(), + getUserPublicProfileByUsername: vi.fn(), +})); + +vi.mock("../services/referrals", () => ({ + getReferralStats: vi.fn(), + ensureUserReferralCode: vi.fn(), +})); + +vi.mock("../services/streaks", () => ({ + getStreak: vi.fn(), + repairStreak: vi.fn(), + getUserActivity: vi.fn(), +})); + +vi.mock("../services/phone", () => ({ + sendVerificationCode: vi.fn(), + hashPhoneNumber: (value: string) => `hash:${value}`, + normalizePhoneNumber: (value: string) => value, + verifyOtpWithBruteForceProtection: vi.fn(), +})); + +vi.mock("../middleware/authenticate", () => ({ + authenticate: (req: any, _res: any, next: any) => { + req.user = { sub: "user-1", role: "user" }; + next(); + }, +})); + +vi.mock("../middleware/rate-limit", () => ({ + apiLimiter: (_req: any, _res: any, next: any) => next(), + phoneRateLimit: (_req: any, _res: any, next: any) => next(), +})); + +vi.mock("../services/badges", () => ({ + getBadgesForUser: vi.fn(), +})); + +vi.mock("../lib/redis", () => ({ + redis: { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + }, +})); + +vi.mock("../lib/config", () => ({ + config: { + WEB_URL: "http://localhost:3000", + WEBHOOK_SECRET: "test-secret", + }, +})); + +import usersRouter from "./users"; + +const app = express(); +app.use(express.json()); +app.use("/users", usersRouter); +app.use(errorHandler); + +const activeUser = { + id: "user-1", + status: "active", + suspended_at: null, +}; + +const payoutRows = [ + { + id: "payout-2", + challenge_id: "challenge-2", + amount_usdc: "2.5000000", + status: "sent", + created_at: "2026-06-28T10:00:00.000Z", + settled_at: "2026-06-28T10:05:00.000Z", + tx_hash: "tx-2", + }, + { + id: "payout-1", + challenge_id: "challenge-1", + amount_usdc: "1.0000000", + status: "pending", + created_at: "2026-06-27T10:00:00.000Z", + settled_at: null, + tx_hash: null, + }, + { + id: "payout-extra", + challenge_id: "challenge-extra", + amount_usdc: "9.0000000", + status: "failed", + created_at: "2026-06-26T10:00:00.000Z", + settled_at: null, + tx_hash: null, + }, +]; + +function mockTotals() { + return { + rows: [{ lifetime_earned_usdc: "3.5000000000000000", pending_usdc: "1.0000000000000000" }], + }; +} + +describe("GET /users/me/earnings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findUserById.mockResolvedValue(activeUser); + }); + + it("returns scoped payout records with normalized ledger statuses and totals", async () => { + mocks.query + .mockResolvedValueOnce({ rows: payoutRows.slice(0, 2) }) + .mockResolvedValueOnce(mockTotals()); + + const res = await request(app).get("/users/me/earnings").expect(200); + + expect(mocks.findUserById).toHaveBeenCalledWith("user-1"); + expect(mocks.query.mock.calls[0][0]).toContain("WHERE user_id = $1"); + expect(mocks.query.mock.calls[0][1]).toEqual(["user-1", 26]); + expect(res.body).toEqual({ + items: [ + { + payout_id: "payout-2", + amount_usdc: "2.5000000", + status: "settled", + created_at: "2026-06-28T10:00:00.000Z", + settled_at: "2026-06-28T10:05:00.000Z", + stellar_tx_hash: "tx-2", + challenge_id: "challenge-2", + }, + { + payout_id: "payout-1", + amount_usdc: "1.0000000", + status: "pending", + created_at: "2026-06-27T10:00:00.000Z", + settled_at: null, + stellar_tx_hash: null, + challenge_id: "challenge-1", + }, + ], + nextCursor: null, + totals: { + lifetime_earned_usdc: "3.5000000000000000", + pending_usdc: "1.0000000000000000", + }, + }); + }); + + it("applies status filtering for settled payouts", async () => { + mocks.query + .mockResolvedValueOnce({ rows: [payoutRows[0]] }) + .mockResolvedValueOnce(mockTotals()); + + await request(app).get("/users/me/earnings?status=settled").expect(200); + + expect(mocks.query.mock.calls[0][0]).toContain("status IN ('sent', 'confirmed')"); + expect(mocks.query.mock.calls[0][1]).toEqual(["user-1", 26]); + }); + + it("returns a cursor when more rows exist than the requested limit", async () => { + mocks.query.mockResolvedValueOnce({ rows: payoutRows }).mockResolvedValueOnce(mockTotals()); + + const res = await request(app).get("/users/me/earnings?limit=2").expect(200); + + expect(res.body.items).toHaveLength(2); + expect(res.body.nextCursor).toBeTypeOf("string"); + const decoded = JSON.parse(Buffer.from(res.body.nextCursor, "base64url").toString("utf8")); + expect(decoded).toEqual({ + created_at: "2026-06-26T10:00:00.000Z", + id: "payout-extra", + }); + }); + + it("uses cursor predicates on subsequent pages", async () => { + const cursor = Buffer.from( + JSON.stringify({ created_at: "2026-06-27T10:00:00.000Z", id: "payout-1" }) + ).toString("base64url"); + mocks.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce(mockTotals()); + + await request(app).get(`/users/me/earnings?cursor=${cursor}`).expect(200); + + expect(mocks.query.mock.calls[0][0]).toContain("created_at < $2"); + expect(mocks.query.mock.calls[0][1]).toEqual([ + "user-1", + "2026-06-27T10:00:00.000Z", + "payout-1", + 26, + ]); + }); +}); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 822b379..24afd17 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -20,6 +20,7 @@ import { verifyOtpWithBruteForceProtection, } from "../services/phone"; import { authenticate } from "../middleware/authenticate"; +import { requireActiveUser } from "../middleware/require-active-user"; import { createError } from "../middleware/error"; import { redis } from "../lib/redis"; import { apiLimiter, phoneRateLimit } from "../middleware/rate-limit"; @@ -28,6 +29,40 @@ import { config } from "../lib/config"; const router: Router = Router(); +const EarningsQuerySchema = z.object({ + status: z.enum(["pending", "settled", "failed", "all"]).default("all"), + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z.string().optional(), +}); + +function encodeEarningsCursor(row: { created_at: string; id: string }): string { + return Buffer.from(JSON.stringify({ created_at: row.created_at, id: row.id })).toString( + "base64url" + ); +} + +function decodeEarningsCursor( + cursor: string | undefined +): { created_at: string; id: string } | null { + if (!cursor) return null; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { + created_at?: unknown; + id?: unknown; + }; + if (typeof parsed.created_at !== "string" || typeof parsed.id !== "string") return null; + return { created_at: parsed.created_at, id: parsed.id }; + } catch { + return null; + } +} + +function toLedgerStatus(status: string): "pending" | "settled" | "failed" { + if (status === "confirmed" || status === "sent") return "settled"; + if (status === "failed") return "failed"; + return "pending"; +} + /** * GET /users/me * Full profile of the authenticated user. @@ -150,6 +185,92 @@ router.get("/me/referrals", authenticate, async (req, res) => { }); }); +router.get("/me/earnings", authenticate, requireActiveUser, async (req, res) => { + const parsed = EarningsQuerySchema.safeParse(req.query); + if (!parsed.success) throw createError("Invalid query parameters", 400, "INVALID_QUERY"); + + const { status, limit } = parsed.data; + const cursor = decodeEarningsCursor(parsed.data.cursor); + if (parsed.data.cursor && !cursor) throw createError("Invalid cursor", 400, "INVALID_CURSOR"); + + const params: unknown[] = [req.user!.sub]; + const where: string[] = ["user_id = $1"]; + + if (status !== "all") { + if (status === "settled") { + where.push(`status IN ('sent', 'confirmed')`); + } else { + params.push(status); + where.push(`status = $${params.length}`); + } + } + + if (cursor) { + params.push(cursor.created_at, cursor.id); + where.push( + `(created_at < $${params.length - 1} OR (created_at = $${params.length - 1} AND id < $${params.length}))` + ); + } + + params.push(limit + 1); + + const rows = await query<{ + id: string; + challenge_id: string; + amount_usdc: string; + status: string; + created_at: string; + settled_at: string | null; + tx_hash: string | null; + }>( + `SELECT + id, + challenge_id, + (amount_stroops::numeric / 10000000)::numeric(20,7)::text AS amount_usdc, + status, + created_at, + CASE WHEN status IN ('sent', 'confirmed') THEN updated_at ELSE NULL END AS settled_at, + tx_hash + FROM payouts + WHERE ${where.join(" AND ")} + ORDER BY created_at DESC, id DESC + LIMIT $${params.length}`, + params + ); + + const totals = await query<{ + lifetime_earned_usdc: string; + pending_usdc: string; + }>( + `SELECT + COALESCE(SUM(amount_stroops) FILTER (WHERE status IN ('sent', 'confirmed')), 0)::numeric / 10000000 AS lifetime_earned_usdc, + COALESCE(SUM(amount_stroops) FILTER (WHERE status = 'pending'), 0)::numeric / 10000000 AS pending_usdc + FROM payouts + WHERE user_id = $1`, + [req.user!.sub] + ); + + const pageRows = rows.rows.slice(0, limit); + const nextRow = rows.rows.length > limit ? rows.rows[limit] : null; + + res.json({ + items: pageRows.map((row) => ({ + payout_id: row.id, + amount_usdc: row.amount_usdc, + status: toLedgerStatus(row.status), + created_at: row.created_at, + settled_at: row.settled_at, + stellar_tx_hash: row.tx_hash ?? null, + challenge_id: row.challenge_id, + })), + nextCursor: nextRow ? encodeEarningsCursor(nextRow) : null, + totals: { + lifetime_earned_usdc: totals.rows[0]?.lifetime_earned_usdc ?? "0", + pending_usdc: totals.rows[0]?.pending_usdc ?? "0", + }, + }); +}); + router.post("/streaks/repair", authenticate, async (req, res) => { const repaired = await repairStreak(req.user!.sub); if (!repaired) { @@ -267,10 +388,7 @@ router.patch("/me/profile", authenticate, async (req, res) => { } // Trigger Next.js cache revalidation so profile pages reflect the new data - const revalidatePaths = [ - `/profile/${oldUsername}`, - `/profile/${newUsername}`, - ]; + const revalidatePaths = [`/profile/${oldUsername}`, `/profile/${newUsername}`]; try { await fetch(`${config.WEB_URL}/api/revalidate`, { @@ -387,10 +505,9 @@ router.patch("/me/notifications/:id/read", authenticate, async (req, res) => { * Marks all unread notifications as read. */ router.patch("/me/notifications/read-all", authenticate, async (req, res) => { - await query( - `UPDATE notifications SET read_at = NOW() WHERE user_id = $1 AND read_at IS NULL`, - [req.user!.sub] - ); + await query(`UPDATE notifications SET read_at = NOW() WHERE user_id = $1 AND read_at IS NULL`, [ + req.user!.sub, + ]); res.json({ success: true }); });