-
Notifications
You must be signed in to change notification settings - Fork 13
feat: Leaderboard privacy toggle, 60s cache, and own-rank display (closes #13) #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,15 +2,42 @@ import { Request, Response } from 'express'; | |
| import { prisma } from '../config/db'; | ||
| import { calculateLevel } from '../config/xpConfig'; | ||
|
|
||
| // ─── In-memory leaderboard cache (60s TTL) ─── | ||
| const leaderboardCache = new Map<string, { data: any; expiresAt: number }>(); | ||
| const CACHE_TTL_MS = 60_000; | ||
|
|
||
| function getCachedLeaderboard(key: string) { | ||
| const entry = leaderboardCache.get(key); | ||
| if (entry && Date.now() < entry.expiresAt) return entry.data; | ||
| leaderboardCache.delete(key); | ||
| return null; | ||
| } | ||
|
|
||
| function setCachedLeaderboard(key: string, data: any) { | ||
| leaderboardCache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); | ||
| } | ||
|
Comment on lines
+5
to
+18
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Unbounded cache growth from unvalidated query params.
🛡️ Proposed fix: validate/clamp inputs and bound cache size- const { limit = 10, sortBy = 'xp' } = req.query;
- const cacheKey = `lb:${sortBy}:${limit}`;
+ const allowedSortBy = ['xp', 'solved', 'streak'];
+ const sortBy = allowedSortBy.includes(req.query.sortBy as string) ? req.query.sortBy as string : 'xp';
+ const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
+ const cacheKey = `lb:${sortBy}:${limit}`;Also applies to: 24-31, 59-59 🤖 Prompt for AI Agents |
||
|
|
||
| // @desc Get leaderboard data | ||
| // @route GET /api/users/leaderboard | ||
| // @access Public | ||
| export const getLeaderboard = async (req: Request, res: Response) => { | ||
| try { | ||
| const { limit = 10, sortBy = 'xp' } = req.query; | ||
| const cacheKey = `lb:${sortBy}:${limit}`; | ||
|
|
||
| const cached = getCachedLeaderboard(cacheKey); | ||
| if (cached) { | ||
| res.setHeader('X-Cache', 'HIT'); | ||
| return res.status(200).json(cached); | ||
| } | ||
|
|
||
| let pipeline: any[] = []; | ||
|
|
||
| // Filter out users who opted out of the leaderboard | ||
| pipeline.push({ | ||
| $match: { isPublic: { $ne: false } } | ||
| }); | ||
|
|
||
| pipeline.push({ | ||
| $project: { | ||
| name: 1, | ||
|
|
@@ -44,6 +71,8 @@ export const getLeaderboard = async (req: Request, res: Response) => { | |
| rank: index + 1 | ||
| })); | ||
|
|
||
| setCachedLeaderboard(cacheKey, formattedLeaderboard); | ||
| res.setHeader('X-Cache', 'MISS'); | ||
| res.status(200).json(formattedLeaderboard); | ||
|
|
||
| } catch (error) { | ||
|
|
@@ -52,6 +81,56 @@ export const getLeaderboard = async (req: Request, res: Response) => { | |
| } | ||
| }; | ||
|
|
||
| // @desc Get authenticated user's own rank (always shown, even if isPublic: false) | ||
| // @route GET /api/users/leaderboard/me | ||
| // @access Private | ||
| export const getMyLeaderboardRank = async (req: Request | any, res: Response) => { | ||
| try { | ||
| const userId = req.user.id; | ||
| const { sortBy = 'xp' } = req.query; | ||
|
|
||
| const user = await prisma.user.findUnique({ where: { id: userId } }); | ||
| if (!user) return res.status(404).json({ message: 'User not found' }); | ||
|
|
||
| // Count how many public users rank above this user | ||
| const sortField = sortBy === 'solved' ? 'solvedProblems' : sortBy === 'streak' ? 'streak_days' : 'xp_points'; | ||
|
|
||
| let rankPipeline: any[] = [ | ||
| { $match: { isPublic: { $ne: false } } }, | ||
| ]; | ||
|
|
||
| if (sortBy === 'solved') { | ||
| rankPipeline.push({ | ||
| $addFields: { solvedCount: { $size: { $ifNull: ["$solvedProblems", []] } } } | ||
| }); | ||
| rankPipeline.push({ $match: { solvedCount: { $gt: user.solvedProblems?.length || 0 } } }); | ||
| } else { | ||
| const userVal = user[sortField as keyof typeof user] as number || 0; | ||
| rankPipeline.push({ $match: { [sortField]: { $gt: userVal } } }); | ||
| } | ||
|
|
||
| rankPipeline.push({ $count: 'above' }); | ||
|
|
||
| const rankResult = await prisma.user.aggregateRaw({ pipeline: rankPipeline }) as unknown as any[]; | ||
| const rank = (rankResult[0]?.above || 0) + 1; | ||
|
|
||
| const solvedCount = user.solvedProblems?.length || 0; | ||
|
|
||
| res.status(200).json({ | ||
| id: user.id, | ||
| name: user.name, | ||
| avatar: user.avatar || (user.name ? user.name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase() : 'U'), | ||
| xp: user.xp_points || 0, | ||
| streak: user.streak_days || 0, | ||
| solved: solvedCount, | ||
| rank, | ||
| }); | ||
| } catch (error) { | ||
| console.error(error); | ||
| res.status(500).json({ message: 'Server Error' }); | ||
| } | ||
| }; | ||
|
|
||
| // @desc Get dashboard stats for logged-in user (rank, streak, weekly activity) | ||
| // @route GET /api/users/dashboard-stats | ||
| // @access Private | ||
|
|
@@ -162,7 +241,7 @@ export const getUserProfile = async (req: Request, res: Response) => { | |
| export const updateUserProfile = async (req: Request | any, res: Response) => { | ||
| try { | ||
| const userId = req.params.userId; | ||
| const { bio, avatarUrl } = req.body; | ||
| const { bio, avatarUrl, isPublic } = req.body; | ||
|
|
||
| const user = await prisma.user.findUnique({ where: { id: userId } }); | ||
|
|
||
|
|
@@ -179,6 +258,7 @@ export const updateUserProfile = async (req: Request | any, res: Response) => { | |
| data: { | ||
| bio: bio !== undefined ? bio : user.bio, | ||
| avatarUrl: avatarUrl !== undefined ? avatarUrl : user.avatarUrl, | ||
| isPublic: isPublic !== undefined ? Boolean(isPublic) : user.isPublic, | ||
| }, | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Optimistic toggle doesn't roll back on HTTP error responses.
handleTogglePrivacyonly revertsisPublicinside thecatchblock, which only fires on network failure. The backend'supdateUserProfilecan return 403 (wrong user), 404, or 500 (perbackend/src/controllers/userController.ts) withoutfetchthrowing — in those cases the optimistic state is never reverted, so the UI silently shows a privacy state the server never persisted.🐛 Proposed fix
const handleTogglePrivacy = async () => { if (!profile) return; const newValue = !isPublic; setIsPublic(newValue); try { const token = localStorage.getItem('token'); - await fetch(`${API_BASE_URL}/api/users/${userId}/profile`, { + const res = await fetch(`${API_BASE_URL}/api/users/${userId}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ isPublic: newValue }), }); + if (!res.ok) { + setIsPublic(!newValue); + } } catch { // Revert on error setIsPublic(!newValue); } };📝 Committable suggestion
🤖 Prompt for AI Agents