Critical – money integrity:
- server.ts: restrict Socket.IO CORS to NEXT_PUBLIC_SITE_URL (remove origin:"*")
- server.ts: validate userId against DB on join_room before any debit/credit
- server.ts: atomic coin-flip joiner claim via updateMany(where:{status:WAITING,joinerId:null})
- server.ts: await pong game_over payout+room update before emitting result; log errors
- prediction/route.ts: wrap market resolve + all payouts in one Prisma transaction;
concurrent PATCH returns 409; dust remainder credited to first winner
High – data truth:
- raised/page.tsx, leaderboard/route.ts, cards/route.ts: add status:"succeeded"
filter to all donation aggregations
- polls/next-president/route.ts: replace full in-memory row scan with DB-side
groupBy + bounded 14-day window for daily activity chart
Medium – ops:
- faq-board/page.tsx: add admin approve/reject tab (visible to ADMIN role)
Low – UX/consistency:
- faq-board, billboard, spotlight, cards: replace hardcoded "BWT" with creditTicker()
- faq-board: read submitCost/voteCost from API response instead of hardcoded values
- WalletActions.tsx: make refreshBalance stable with useCallback; remove
eslint-disable exhaustive-deps suppression
Co-authored-by: Cursor <cursoragent@cursor.com>
52 lines
1.6 KiB
TypeScript
52 lines
1.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const revalidate = 0;
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const url = new URL(req.url);
|
|
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "25"), 100);
|
|
|
|
// Aggregate total donated per user
|
|
const rows = await prisma.donation.groupBy({
|
|
by: ["userId"],
|
|
where: { userId: { not: null }, status: "succeeded" },
|
|
_sum: { amountUsdCents: true },
|
|
_count: { id: true },
|
|
orderBy: { _sum: { amountUsdCents: "desc" } },
|
|
take: limit,
|
|
});
|
|
|
|
// Fetch display names
|
|
const userIds = rows.map((r) => r.userId).filter((id): id is string => id != null);
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, name: true, email: true },
|
|
});
|
|
|
|
const userMap = new Map(users.map(u => [u.id, u]));
|
|
|
|
const leaderboard = rows.map((row, idx) => {
|
|
const uid = row.userId!;
|
|
const user = userMap.get(uid);
|
|
// Show name if present, else obfuscate email
|
|
let displayName = user?.name ?? "Anonymous";
|
|
// Ghost donors have emails ending in leaderboard.local — show name only
|
|
// Real donors: show first name + last initial, or truncated email
|
|
const email = user?.email ?? "";
|
|
if (!user?.name && !email.endsWith("leaderboard.local")) {
|
|
const [local] = email.split("@");
|
|
displayName = local.slice(0, 3) + "***";
|
|
}
|
|
return {
|
|
rank: idx + 1,
|
|
displayName,
|
|
totalUsdCents: row._sum.amountUsdCents ?? 0,
|
|
donationCount: row._count.id,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({ leaderboard });
|
|
}
|