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
208 changes: 208 additions & 0 deletions apps/api/src/routes/users.earnings.test.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
133 changes: 125 additions & 8 deletions apps/api/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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`, {
Expand Down Expand Up @@ -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 });
});
Expand Down