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
42 changes: 32 additions & 10 deletions app/src/sections/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ interface LeaderboardEntry {
}

export function Leaderboard({ onProfileClick }: LeaderboardProps) {
const { profile } = useAuth();
const { profile, token } = useAuth();
const [timeRange, setTimeRange] = useState<'all' | 'month' | 'week'>('all');
const [category, setCategory] = useState<'xp' | 'streak' | 'solved'>('xp');
const [leaderboardData, setLeaderboardData] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<any>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
Expand All @@ -52,6 +53,27 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) {
fetchLeaderboard();
}, [category]);

// Fetch own rank separately (always shown, even if hidden from public leaderboard)
useEffect(() => {
if (!profile) return;
const fetchMyRank = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const res = await fetch(`${API_BASE_URL}/api/users/leaderboard/me?sortBy=${category}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setMyRank(data);
}
} catch {
// Silently ignore — own rank is best-effort
}
};
fetchMyRank();
}, [profile, category]);

const getRankIcon = (rank: number) => {
if (rank === 1) return <Crown className="w-6 h-6 text-[#ffd700]" />;
if (rank === 2) return <Medal className="w-6 h-6 text-[#c0c0c0]" />;
Expand Down Expand Up @@ -265,8 +287,8 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) {
))}
</motion.div>

{/* Current User Rank - TODO: Implement finding user rank from backend */}
{profile && (
{/* Current User Rank */}
{profile && myRank && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
Expand All @@ -275,14 +297,14 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) {
>
<div className="flex items-center gap-4">
<div className="w-8 flex justify-center">
<span className="text-white/60 font-medium">#?</span>
<span className="text-[#a088ff] font-bold">#{myRank.rank}</span>
</div>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#a088ff] to-[#63e3ff] flex items-center justify-center overflow-hidden">
{profile.avatar?.startsWith('http') ? (
<img src={profile.avatar} alt={profile.name} className="w-full h-full object-cover" />
{myRank.avatar?.startsWith('http') ? (
<img src={myRank.avatar} alt={myRank.name} className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-[#141414]">
{profile.name?.charAt(0).toUpperCase() || 'Y'}
{myRank.avatar || profile.name?.charAt(0).toUpperCase() || 'Y'}
</span>
)}
</div>
Expand All @@ -293,15 +315,15 @@ export function Leaderboard({ onProfileClick }: LeaderboardProps) {
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-sm">
<Zap className="w-4 h-4 text-[#a088ff]" />
<span className="text-white/80">{profile.xp_points || 0}</span>
<span className="text-white/80">{myRank.xp.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Flame className="w-4 h-4 text-[#ff8a63]" />
<span className="text-white/80">{profile.streak_days || 0}</span>
<span className="text-white/80">{myRank.streak}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Target className="w-4 h-4 text-[#63e3ff]" />
<span className="text-white/80">{profile.solvedProblems?.length || 0}</span>
<span className="text-white/80">{myRank.solved}</span>
</div>
</div>
</div>
Expand Down
61 changes: 60 additions & 1 deletion app/src/sections/ProfileView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Zap, Flame, Target, Edit2, Check, X, ArrowLeft, User, Award } from 'lucide-react';
import { Zap, Flame, Target, Edit2, Check, X, ArrowLeft, User, Award, EyeOff, Eye } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
Expand All @@ -15,6 +15,7 @@ interface ProfileData {
solved: number;
level: number;
memberSince: string;
isPublic?: boolean;
}

interface ProfileViewProps {
Expand All @@ -31,6 +32,7 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) {
const [editBio, setEditBio] = useState('');
const [editAvatarUrl, setEditAvatarUrl] = useState('');
const [saving, setSaving] = useState(false);
const [isPublic, setIsPublic] = useState(true);

const isOwner = user?.id === userId;

Expand All @@ -49,6 +51,7 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) {
setProfile(data);
setEditBio(data.bio || '');
setEditAvatarUrl(data.avatar || '');
setIsPublic(data.isPublic !== false);
}
} catch (error) {
console.error('Failed to fetch profile', error);
Expand Down Expand Up @@ -92,6 +95,26 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) {
setIsEditing(false);
};

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`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ isPublic: newValue }),
});
} catch {
// Revert on error
setIsPublic(!newValue);
}
};

Comment on lines +98 to +117

Copy link
Copy Markdown
Contributor

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.

handleTogglePrivacy only reverts isPublic inside the catch block, which only fires on network failure. The backend's updateUserProfile can return 403 (wrong user), 404, or 500 (per backend/src/controllers/userController.ts) without fetch throwing — 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ isPublic: newValue }),
});
} catch {
// Revert on error
setIsPublic(!newValue);
}
};
const handleTogglePrivacy = async () => {
if (!profile) return;
const newValue = !isPublic;
setIsPublic(newValue);
try {
const token = localStorage.getItem('token');
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);
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/sections/ProfileView.tsx` around lines 98 - 117, The optimistic
privacy toggle in handleTogglePrivacy only rolls back on thrown errors, so
non-2xx fetch responses from the profile PUT request can leave isPublic out of
sync with the server. Update the logic in ProfileView.tsx to inspect the
response from fetch for the /api/users/${userId}/profile call, treat HTTP error
statuses as failures, and revert setIsPublic when the request is not successful.
Keep the rollback behavior tied to handleTogglePrivacy so the UI reflects the
backend result.

if (loading) {
return (
<section className="relative min-h-screen pt-24 pb-12 flex items-center justify-center">
Expand Down Expand Up @@ -217,6 +240,42 @@ export function ProfileView({ userId, onBack }: ProfileViewProps) {
</div>
</motion.div>

{/* Privacy Toggle (owner only) */}
{isOwner && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6"
>
<button
onClick={handleTogglePrivacy}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
{isPublic ? (
<Eye className="w-4 h-4 text-emerald-400" />
) : (
<EyeOff className="w-4 h-4 text-white/40" />
)}
<div className="text-left">
<p className="text-white text-sm font-medium">Leaderboard Visibility</p>
<p className="text-white/40 text-xs">
{isPublic ? 'Your profile is visible on the public leaderboard' : 'Your profile is hidden from the public leaderboard'}
</p>
</div>
</div>
<div
className={`relative w-10 h-6 rounded-full transition-colors ${isPublic ? 'bg-emerald-500/30' : 'bg-white/10'}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full transition-all ${isPublic ? 'left-5 bg-emerald-400' : 'left-1 bg-white/40'}`}
/>
</div>
</button>
</motion.div>
)}

{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
Expand Down
1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ model User {
googleId String? @unique
role String @default("user") // "user", "admin"
isBanned Boolean @default(false)
isPublic Boolean @default(true)
avatar String?
avatarUrl String?
bio String? @default("")
Expand Down
82 changes: 81 additions & 1 deletion backend/src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded cache growth from unvalidated query params.

cacheKey = lb:${sortBy}:${limit} is built directly from raw, unvalidated query-string input. A client varying limit (or sortBy) across many values can mint unlimited distinct entries in leaderboardCache, each retained for 60s with no cap on map size — unbounded memory growth (and a large/garbage limit also inflates the $limit aggregation stage, since Number(limit) isn't clamped or validated against NaN).

🛡️ 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/controllers/userController.ts` around lines 4 - 17, Validate and
clamp the raw query inputs used by the leaderboard flow before building the
cache key in userController’s leaderboard-related logic: limit should be parsed
safely, checked for NaN, and bounded to an allowed range, and sortBy should be
restricted to known values. Update the cacheKey generation and the aggregation
setup so they use sanitized values only, and add a size cap or eviction strategy
to leaderboardCache (used by getCachedLeaderboard/setCachedLeaderboard) so
varying query params cannot grow the Map without bound.


// @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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 } });

Expand All @@ -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,
},
});

Expand Down
3 changes: 2 additions & 1 deletion backend/src/routes/userRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import express from 'express';
import { getLeaderboard, getDashboardStats, getUserProfile, updateUserProfile } from '../controllers/userController';
import { getLeaderboard, getMyLeaderboardRank, getDashboardStats, getUserProfile, updateUserProfile } from '../controllers/userController';
import { protect } from '../middleware/authMiddleware';

const router = express.Router();

router.get('/leaderboard', getLeaderboard);
router.get('/leaderboard/me', protect, getMyLeaderboardRank);
router.get('/dashboard-stats', protect, getDashboardStats);
router.get('/:userId/profile', getUserProfile);
router.put('/:userId/profile', protect, updateUserProfile);
Expand Down
Loading