+ {/* Stats grid */}
+
0 ? `best ${maxStreak}d` : undefined} />
+
{/* Divider */}
diff --git a/client/src/pages/Badges.jsx b/client/src/pages/Badges.jsx
index 1c4ff0b..2d56b25 100644
--- a/client/src/pages/Badges.jsx
+++ b/client/src/pages/Badges.jsx
@@ -1,72 +1,394 @@
-import React from 'react';
-import { useQuery } from '@tanstack/react-query';
+import React, { useMemo, useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import PageHeader from '../components/PageHeader';
-import BaseCard from '../components/BaseCard';
import SkeletonCard from '../components/SkeletonCard';
-import { FiLock } from 'react-icons/fi';
+import { motion, AnimatePresence } from 'framer-motion';
+import { FiLock, FiCheck, FiAward, FiFilter, FiStar } from 'react-icons/fi';
+import toast from 'react-hot-toast';
+import BadgeMedal from '../components/BadgeMedal';
-const Badges = () => {
+// ── Rarity config ────────────────────────────────────────────────────────────
+const RARITY = {
+ COMMON: { glow: "0,0,0,0", border: "#334155", lightBorder: "#475569", bg: "#1e293b", label: "#94a3b8" },
+ RARE: { glow: "59,130,246,0.5", border: "#3b82f6", lightBorder: "#2563eb", bg: "#1e3a5f", label: "#60a5fa" },
+ EPIC: { glow: "168,85,247,0.55", border: "#a855f7", lightBorder: "#7e22ce", bg: "#3b1f6e", label: "#c084fc" },
+ LEGENDARY: { glow: "250,204,21,0.65", border: "#facc15", lightBorder: "#a16207", bg: "#422006", label: "#fde047" },
+};
+
+// ── Earn-difficulty config ───────────────────────────────────────────────────
+const DIFFICULTY_CONFIG = {
+ Easy: { label: 'Easy', cls: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25' },
+ Medium: { label: 'Medium', cls: 'bg-amber-500/15 text-amber-400 border-amber-500/25' },
+ Hard: { label: 'Hard', cls: 'bg-rose-500/15 text-rose-400 border-rose-500/25' },
+ Elite: { label: 'Elite', cls: 'bg-violet-500/15 text-violet-400 border-violet-500/25' },
+};
+
+// ── Category order ───────────────────────────────────────────────────────────
+const CATEGORY_ORDER = ['Milestones', 'Streaks', 'Difficulty', 'Precision', 'Languages', 'Special', "Chief's Choice"];
+
+// ── Helper: hex progress bar ─────────────────────────────────────────────────
+const ProgressBar = ({ progress, threshold }) => {
+ const pct = threshold > 0 ? Math.min(100, Math.round((progress / threshold) * 100)) : 0;
+ return (
+
+
+ {progress} / {threshold}
+ {pct}%
+
+
+
+ );
+};
+
+// ── Individual Badge Card ─────────────────────────────────────────────────────
+const BadgeCard = ({ badge, index, onSetFeatured, isFeatured }) => {
+ const isUnlocked = badge.isUnlocked;
+ const isChief = badge.isChiefBadge;
+ const r = RARITY[badge.rarity] || RARITY.COMMON;
+ const diff = DIFFICULTY_CONFIG[badge.earnDifficulty] || DIFFICULTY_CONFIG.Medium;
+
+ return (
+
+ {/* Dynamic background rarity glow */}
+ {isUnlocked && (
+
+ )}
+ {/* Glossy top edge highlight */}
+ {isUnlocked && (
+
+ )}
+ {/* Chief crown badge */}
+ {isChief && (
+
+
+ ★ Chief
+
+
+ )}
+
+ {/* 3D Medal Component with a pedestal effect */}
+
+ {/* Subtle ground shadow for the medal */}
+ {isUnlocked &&
}
+
+
+
+ {/* Content */}
+
+
+ {badge.name}
+
+
+
+ {badge.description}
+
+
+ {/* Progress bar (locked only) */}
+ {!isUnlocked && !isChief && badge.threshold > 1 && (
+
+ )}
+
+ {/* Chief locked */}
+ {!isUnlocked && isChief && (
+
Awarded by Clan Chief
+ )}
+
+ {/* Footer tags */}
+
+
+ {badge.rarity}
+
+ {!isChief && (
+
+ {diff.label}
+
+ )}
+
+
+
+ {/* Unlocked checkmark overlay styled like a gem */}
+ {isUnlocked && (
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+// ── Category Section ──────────────────────────────────────────────────────────
+const CategorySection = ({ category, badges, index, onSetFeatured, featuredBadgeId }) => {
+
+ const unlocked = badges.filter(b => b.isUnlocked).length;
+ const isComplete = unlocked === badges.length;
+
+ const CATEGORY_ICONS = {
+ 'Milestones': '🎯',
+ 'Streaks': '🔥',
+ 'Difficulty': '⚡',
+ 'Precision': '🎯',
+ 'Languages': '🌐',
+ 'Special': '✨',
+ "Chief's Choice":'👑',
+ };
+
+ return (
+
+ {/* Section header */}
+
+
{CATEGORY_ICONS[category] || '🏅'}
+
{category}
+
+
+ {unlocked} / {badges.length}
+
+ {isComplete && }
+
+
+
+
+
+ {badges.map((badge, i) => (
+
+ ))}
+
+
+ );
+};
+
+// ── Main Badges Page ──────────────────────────────────────────────────────────
+const Badges = () => {
+ const queryClient = useQueryClient();
+ const [activeCategory, setActiveCategory] = useState('All');
+ const [showUnlocked, setShowUnlocked] = useState(null); // null=all, true=unlocked, false=locked
const { data: badges = [], isLoading } = useQuery({
queryKey: ['badges'],
queryFn: async () => {
const res = await api.get('/api/badges');
return res.data.data;
- }
+ },
+ staleTime: 60000,
});
- const getRarityColor = (rarity) => {
- switch(rarity) {
- case 'LEGENDARY': return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30';
- case 'EPIC': return 'text-purple-400 bg-purple-400/10 border-purple-400/30';
- case 'RARE': return 'text-blue-400 bg-blue-400/10 border-blue-400/30';
- case 'COMMON': return 'text-green-400 bg-green-400/10 border-green-400/30';
- default: return 'text-gray-400 bg-gray-400/10 border-gray-400/30';
- }
- };
+ // We actually need the user's featuredBadge ID.
+ // We can fetch it via /api/profile/stats which returns user info or /api/auth/me
+ // Wait, the currently logged in user is in auth context. But we can just use the auth/me endpoint or profile
+ const { data: authUser } = useQuery({
+ queryKey: ['authUser'],
+ queryFn: async () => {
+ const res = await api.get('/api/auth/me');
+ return res.data.data;
+ },
+ staleTime: 60000,
+ });
+
+ // featuredBadge may be a populated object or a raw ID string
+ const featuredBadgeId = authUser?.featuredBadge?._id
+ ? authUser.featuredBadge._id.toString()
+ : authUser?.featuredBadge?.toString() || null;
+
+ const setFeaturedMutation = useMutation({
+ mutationFn: async (badgeId) => {
+ await api.put('/api/profile/featured-badge', { badgeId });
+ },
+ onSuccess: () => {
+ toast.success('Featured badge updated!');
+ queryClient.invalidateQueries(['authUser']);
+ queryClient.invalidateQueries(['profile-stats']);
+ },
+ onError: (err) => toast.error(err.response?.data?.message || 'Failed to update featured badge'),
+ });
+
+ // Group badges by category
+ const groupedBadges = useMemo(() => {
+ const groups = {};
+ badges.forEach(b => {
+ const cat = b.isChiefBadge ? "Chief's Choice" : (b.category || 'Special');
+ if (!groups[cat]) groups[cat] = [];
+ groups[cat].push(b);
+ });
+ return groups;
+ }, [badges]);
+
+ // Categories for filter tabs
+ const categories = useMemo(() => {
+ return ['All', ...CATEGORY_ORDER.filter(c => groupedBadges[c])];
+ }, [groupedBadges]);
+
+ // Stats
+ const totalUnlocked = badges.filter(b => b.isUnlocked).length;
+ const totalBadges = badges.length;
+ const pct = totalBadges > 0 ? Math.round((totalUnlocked / totalBadges) * 100) : 0;
+
+ // Filtered groups
+ const filteredGroups = useMemo(() => {
+ const result = {};
+ Object.entries(groupedBadges).forEach(([cat, catBadges]) => {
+ if (activeCategory !== 'All' && cat !== activeCategory) return;
+ const filtered = showUnlocked === null ? catBadges : catBadges.filter(b => b.isUnlocked === showUnlocked);
+ if (filtered.length > 0) result[cat] = filtered;
+ });
+ return result;
+ }, [groupedBadges, activeCategory, showUnlocked]);
return (
-
+
+ {/* Page Header */}
- {isLoading ? (
-
-
+ {/* Progress Overview Card */}
+ {!isLoading && (
+
+
+
+
+
+
+
+
{totalUnlocked} / {totalBadges}
+
Badges unlocked
+
+
+
+
+ Overall Progress
+ {pct}%
+
+
+
+
- ) : (
-
- {badges.map((badge) => {
- const isUnlocked = badge.isUnlocked;
-
- return (
-
+ {/* Category tabs */}
+
+ {categories.map(cat => (
+
+ ))}
+
+
+ {/* Status filter */}
+
+ {[
+ { label: 'All', val: null },
+ { label: '✓ Earned', val: true },
+ { label: '🔒 Locked', val: false },
+ ].map(({ label, val }) => (
+
+ ))}
+
)}
+
+ {/* Badge Grid */}
+ {isLoading ? (
+
+ {Array.from({ length: 10 }).map((_, i) => )}
+
+ ) : (
+
+
+ {CATEGORY_ORDER.filter(cat => filteredGroups[cat]).map((cat, idx) => (
+
+ ))}
+ {Object.keys(filteredGroups).length === 0 && (
+
+
+
No badges match this filter.
+
+ )}
+
+
+ )}
);
};
diff --git a/client/src/pages/ClanChiefPanel.jsx b/client/src/pages/ClanChiefPanel.jsx
index e203372..a414d63 100644
--- a/client/src/pages/ClanChiefPanel.jsx
+++ b/client/src/pages/ClanChiefPanel.jsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
-import { FiUsers, FiActivity, FiShield, FiFileText, FiBell, FiAlertCircle } from 'react-icons/fi';
+import { FiUsers, FiActivity, FiShield, FiFileText, FiBell, FiAlertCircle, FiAward } from 'react-icons/fi';
import { clsx } from 'clsx';
import { api } from '../lib/api';
@@ -10,14 +10,16 @@ import PermissionLegend from '../components/PermissionLegend';
import ChiefDashboardTab from './chief/ChiefDashboardTab';
import ChiefMembersTab from './chief/ChiefMembersTab';
import ChiefReviewTab from './chief/ChiefReviewTab';
+import ChiefBadgesTab from './chief/ChiefBadgesTab';
const ClanChiefPanel = () => {
const [activeTab, setActiveTab] = useState('dashboard');
const tabs = [
- { id: 'dashboard', label: 'Clan Overview', icon: FiActivity },
- { id: 'members', label: 'Member Roster', icon: FiUsers },
- { id: 'review', label: 'Review Submissions', icon: FiFileText },
+ { id: 'dashboard', label: 'Clan Overview', icon: FiActivity },
+ { id: 'members', label: 'Member Roster', icon: FiUsers },
+ { id: 'review', label: 'Review Submissions', icon: FiFileText },
+ { id: 'badges', label: 'Award Badges', icon: FiAward },
];
const chiefQuery = useQuery({
@@ -112,8 +114,9 @@ const ClanChiefPanel = () => {
) : (
<>
{activeTab === 'dashboard' &&
}
- {activeTab === 'members' &&
}
- {activeTab === 'review' &&
}
+ {activeTab === 'members' &&
}
+ {activeTab === 'review' &&
}
+ {activeTab === 'badges' &&
}
>
)}
diff --git a/client/src/pages/Clans.jsx b/client/src/pages/Clans.jsx
index 5283bda..c07358c 100644
--- a/client/src/pages/Clans.jsx
+++ b/client/src/pages/Clans.jsx
@@ -59,6 +59,38 @@ const ClanDashboard = ({ clan, userId, onLeave, readOnly, onBack }) => {
const isArchived = clan.status === 'archived';
const [viewMode, setViewMode] = useState('grid');
+ // Fetch member badges to determine Star Performer
+ const { data: memberBadgeMap = {} } = useQuery({
+ queryKey: ['member-badges', clan?._id],
+ queryFn: async () => {
+ if (!members.length) return {};
+ const results = await Promise.all(
+ members.map(async m => {
+ try {
+ const res = await api.get(`/api/badges/user/${m._id}`);
+ const chiefAwarded = (res.data.data || []).filter(b => b.isChiefBadge && b.isUnlocked);
+ return [m._id, chiefAwarded.length];
+ } catch { return [m._id, 0]; }
+ })
+ );
+ return Object.fromEntries(results);
+ },
+ enabled: !!members.length,
+ staleTime: 60000,
+ });
+
+ const starPerformerId = React.useMemo(() => {
+ let maxBadges = 0;
+ let starId = null;
+ for (const [memId, count] of Object.entries(memberBadgeMap)) {
+ if (count > maxBadges) {
+ maxBadges = count;
+ starId = memId;
+ }
+ }
+ return maxBadges > 0 ? starId : null;
+ }, [memberBadgeMap]);
+
return (
{/* Header Banner */}
@@ -176,6 +208,16 @@ const ClanDashboard = ({ clan, userId, onLeave, readOnly, onBack }) => {
🔥 {member.streak || 0}
+ {memberBadgeMap[member._id] > 0 && (
+
+ {memberBadgeMap[member._id]}
+
+ )}
+ {starPerformerId === member._id && (
+
+ ✨ Star Performer
+
+ )}
{isMemberChief ? (
CHIEF
diff --git a/client/src/pages/chief/ChiefBadgesTab.jsx b/client/src/pages/chief/ChiefBadgesTab.jsx
new file mode 100644
index 0000000..df1f70b
--- /dev/null
+++ b/client/src/pages/chief/ChiefBadgesTab.jsx
@@ -0,0 +1,289 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api } from '../../lib/api';
+import { motion, AnimatePresence } from 'framer-motion';
+import { FiAward, FiX, FiCheck, FiSearch, FiUser } from 'react-icons/fi';
+import toast from 'react-hot-toast';
+
+const RARITY_CONFIG = {
+ COMMON: { text: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20' },
+ RARE: { text: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
+ EPIC: { text: 'text-purple-400', bg: 'bg-purple-500/10 border-purple-500/20' },
+ LEGENDARY: { text: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20' },
+};
+
+// ── Award Badge Modal ─────────────────────────────────────────────────────────
+const AwardBadgeModal = ({ member, chiefBadges, onClose, onAward }) => {
+ const [selected, setSelected] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const handleAward = async () => {
+ if (!selected) return;
+ setLoading(true);
+ await onAward(member._id, selected._id);
+ setLoading(false);
+ onClose();
+ };
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+
+
Award Chief Badge
+
+ To: {member.name || member.username}
+
+
+
+
+
+ {/* Badge Picker */}
+
+
Select a Badge
+
+ {chiefBadges.map(badge => {
+ const r = RARITY_CONFIG[badge.rarity] || RARITY_CONFIG.COMMON;
+ const isSelected = selected?._id === badge._id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+};
+
+// ── Main Component ────────────────────────────────────────────────────────────
+const ChiefBadgesTab = ({ clan }) => {
+ const queryClient = useQueryClient();
+ const [search, setSearch] = useState('');
+ const [awardingMember, setAwardingMember] = useState(null);
+
+ // Fetch chief badge pool
+ const { data: chiefBadges = [] } = useQuery({
+ queryKey: ['chief-badge-pool'],
+ queryFn: async () => {
+ const res = await api.get('/api/badges/chief');
+ return res.data.data;
+ },
+ });
+
+ // Fetch member badges
+ const { data: memberBadgeMap = {}, isLoading } = useQuery({
+ queryKey: ['member-badges', clan?._id],
+ queryFn: async () => {
+ if (!clan?.members?.length) return {};
+ const results = await Promise.all(
+ clan.members.map(async m => {
+ try {
+ const res = await api.get(`/api/badges/user/${m._id}`);
+ const chiefAwarded = (res.data.data || []).filter(b => b.isChiefBadge && b.isUnlocked);
+ return [m._id, chiefAwarded];
+ } catch { return [m._id, []]; }
+ })
+ );
+ return Object.fromEntries(results);
+ },
+ enabled: !!clan?.members?.length,
+ });
+
+ const awardMutation = useMutation({
+ mutationFn: async ({ userId, badgeId }) => {
+ await api.post(`/api/badges/award/${userId}`, { badgeId });
+ },
+ onSuccess: () => {
+ toast.success('Badge awarded successfully! 🏆');
+ queryClient.invalidateQueries({ queryKey: ['member-badges'] });
+ },
+ onError: (err) => toast.error(err.response?.data?.message || 'Failed to award badge.'),
+ });
+
+ const revokeMutation = useMutation({
+ mutationFn: async ({ userId, badgeId }) => {
+ await api.delete(`/api/badges/revoke/${userId}/${badgeId}`);
+ },
+ onSuccess: () => {
+ toast.success('Badge revoked.');
+ queryClient.invalidateQueries({ queryKey: ['member-badges'] });
+ },
+ onError: (err) => toast.error(err.response?.data?.message || 'Failed to revoke badge.'),
+ });
+
+ const members = (clan?.members || []).filter(m => {
+ if (!search.trim()) return true;
+ const q = search.toLowerCase();
+ return (m.name || '').toLowerCase().includes(q) || (m.username || '').toLowerCase().includes(q);
+ });
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Award Chief Badges
+
+
+ Recognize outstanding members with honorary badges. These badges appear permanently on their profile.
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {/* Chief badge pool preview */}
+
+ {chiefBadges.map(b => (
+
+ {b.icon}
+ {b.name}
+
+ ))}
+
+
+ {/* Member list */}
+ {isLoading ? (
+
+ ) : (
+
+ {members.map(member => {
+ const awarded = memberBadgeMap[member._id] || [];
+ return (
+
+ {/* Avatar */}
+
+
+ {member.profilePicture
+ ?

+ :
+ }
+
+
+
{member.name || member.username}
+
@{member.username}
+
+
+
+ {/* Awarded badges */}
+
+ {awarded.length === 0 && (
+
No chief badges yet
+ )}
+ {awarded.map(badge => (
+
+ {badge.icon}
+ {badge.name}
+
+
+ ))}
+
+
+ {/* Award button */}
+
+
+ );
+ })}
+
+ {members.length === 0 && (
+
+ )}
+
+ )}
+
+ {/* Award modal */}
+
+ {awardingMember && (
+ setAwardingMember(null)}
+ onAward={async (userId, badgeId) => {
+ await awardMutation.mutateAsync({ userId, badgeId });
+ }}
+ />
+ )}
+
+
+ );
+};
+
+export default ChiefBadgesTab;
diff --git a/server/src/features/auth/auth.controller.js b/server/src/features/auth/auth.controller.js
index 6ba7b5a..76fd14d 100644
--- a/server/src/features/auth/auth.controller.js
+++ b/server/src/features/auth/auth.controller.js
@@ -549,7 +549,7 @@ const logoutAll = async (req, res, next) => {
const getMe = async (req, res, next) => {
try {
- const user = await User.findById(req.user.id).populate('clan', 'name').lean();
+ const user = await User.findById(req.user.id).populate('clan', 'name').populate('featuredBadge').lean();
if (!user) {
res.status(404);
throw new Error('User not found');
diff --git a/server/src/features/badges/Badge.model.js b/server/src/features/badges/Badge.model.js
index 4b2525b..fb7d5b6 100644
--- a/server/src/features/badges/Badge.model.js
+++ b/server/src/features/badges/Badge.model.js
@@ -1,11 +1,13 @@
const mongoose = require('mongoose');
const badgeSchema = new mongoose.Schema({
- name: { type: String, required: true },
+ name: { type: String, required: true, unique: true },
icon: { type: String, required: true },
- color: { type: String, enum: ['blue', 'gold', 'green', 'red', 'purple'], default: 'blue' },
+ color: { type: String, enum: ['blue', 'gold', 'green', 'red', 'purple', 'orange', 'cyan', 'pink'], default: 'blue' },
rarity: { type: String, enum: ['COMMON', 'RARE', 'EPIC', 'LEGENDARY'], default: 'COMMON' },
- description: { type: String }
+ description: { type: String },
+ earnDifficulty: { type: String, enum: ['Easy', 'Medium', 'Hard', 'Elite'], default: 'Medium' },
+ isChiefBadge: { type: Boolean, default: false }, // manually awarded by clan chief only
}, { timestamps: true });
module.exports = mongoose.model('Badge', badgeSchema);
diff --git a/server/src/features/badges/badge.controller.js b/server/src/features/badges/badge.controller.js
index c88755a..85ef908 100644
--- a/server/src/features/badges/badge.controller.js
+++ b/server/src/features/badges/badge.controller.js
@@ -1,14 +1,101 @@
const { getAllBadgesForUser } = require('./badge.service');
+const Badge = require('./Badge.model');
+const User = require('../users/User.model');
-// @desc Get all badges
+// @desc Get all badges for the logged-in user
// @route GET /api/badges
// @access Private
exports.getBadges = async (req, res, next) => {
try {
- const userId = req.user.id;
- const data = await getAllBadgesForUser(userId);
+ const data = await getAllBadgesForUser(req.user.id);
res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
};
+
+// @desc Get all badges for another user (for profile view)
+// @route GET /api/badges/user/:userId
+// @access Private
+exports.getBadgesForUser = async (req, res, next) => {
+ try {
+ const data = await getAllBadgesForUser(req.params.userId);
+ res.status(200).json({ success: true, data });
+ } catch (error) {
+ next(error);
+ }
+};
+
+// @desc Award a chief badge to a clan member
+// @route POST /api/badges/award/:userId
+// @access Private (clan-chief or admin)
+exports.awardBadge = async (req, res, next) => {
+ try {
+ const { badgeId } = req.body;
+ const { userId } = req.params;
+
+ if (!badgeId) return res.status(400).json({ success: false, message: 'badgeId is required.' });
+
+ const badge = await Badge.findById(badgeId);
+ if (!badge || !badge.isChiefBadge) {
+ return res.status(400).json({ success: false, message: 'Invalid badge or not a Chief badge.' });
+ }
+
+ // Verify the chief is awarding within their own clan
+ const chief = await User.findById(req.user.id).select('clan role');
+ const member = await User.findById(userId).select('clan');
+ if (!member) return res.status(404).json({ success: false, message: 'User not found.' });
+
+ if (chief.role !== 'admin' && chief.role !== 'superAdmin') {
+ if (!chief.clan || !member.clan || chief.clan.toString() !== member.clan.toString()) {
+ return res.status(403).json({ success: false, message: 'You can only award badges to members of your own clan.' });
+ }
+ }
+
+ await User.findByIdAndUpdate(userId, { $addToSet: { awardedBadgeIds: badgeId } });
+ res.status(200).json({ success: true, message: `Badge "${badge.name}" awarded successfully.` });
+ } catch (error) {
+ next(error);
+ }
+};
+
+// @desc Revoke a chief badge from a clan member
+// @route DELETE /api/badges/revoke/:userId/:badgeId
+// @access Private (clan-chief or admin)
+exports.revokeBadge = async (req, res, next) => {
+ try {
+ const { userId, badgeId } = req.params;
+
+ const badge = await Badge.findById(badgeId);
+ if (!badge || !badge.isChiefBadge) {
+ return res.status(400).json({ success: false, message: 'Invalid badge or not a Chief badge.' });
+ }
+
+ const chief = await User.findById(req.user.id).select('clan role');
+ const member = await User.findById(userId).select('clan');
+ if (!member) return res.status(404).json({ success: false, message: 'User not found.' });
+
+ if (chief.role !== 'admin' && chief.role !== 'superAdmin') {
+ if (!chief.clan || !member.clan || chief.clan.toString() !== member.clan.toString()) {
+ return res.status(403).json({ success: false, message: 'You can only manage badges for members of your own clan.' });
+ }
+ }
+
+ await User.findByIdAndUpdate(userId, { $pull: { awardedBadgeIds: badgeId } });
+ res.status(200).json({ success: true, message: `Badge "${badge.name}" revoked.` });
+ } catch (error) {
+ next(error);
+ }
+};
+
+// @desc Get all chief badges (for picker UI)
+// @route GET /api/badges/chief
+// @access Private
+exports.getChiefBadges = async (req, res, next) => {
+ try {
+ const badges = await Badge.find({ isChiefBadge: true }).sort({ rarity: -1 });
+ res.status(200).json({ success: true, data: badges });
+ } catch (error) {
+ next(error);
+ }
+};
diff --git a/server/src/features/badges/badge.routes.js b/server/src/features/badges/badge.routes.js
index a588e70..f855c34 100644
--- a/server/src/features/badges/badge.routes.js
+++ b/server/src/features/badges/badge.routes.js
@@ -1,8 +1,21 @@
const express = require('express');
const router = express.Router();
-const { getBadges } = require('./badge.controller');
+const { getBadges, getBadgesForUser, awardBadge, revokeBadge, getChiefBadges } = require('./badge.controller');
const { protect } = require('../../../middleware/auth');
-router.route('/').get(protect, getBadges);
+// Get own badges
+router.get('/', protect, getBadges);
+
+// Get chief badge pool
+router.get('/chief', protect, getChiefBadges);
+
+// Get badges for another user (profile)
+router.get('/user/:userId', protect, getBadgesForUser);
+
+// Award a chief badge to a member
+router.post('/award/:userId', protect, awardBadge);
+
+// Revoke a chief badge from a member
+router.delete('/revoke/:userId/:badgeId', protect, revokeBadge);
module.exports = router;
diff --git a/server/src/features/badges/badge.service.js b/server/src/features/badges/badge.service.js
index 91d1834..1ea41f1 100644
--- a/server/src/features/badges/badge.service.js
+++ b/server/src/features/badges/badge.service.js
@@ -5,64 +5,53 @@ const User = require('../users/User.model');
/**
* Computes all badges with isUnlocked status for a user.
- * @param {string|mongoose.Types.ObjectId} userId
- * @returns {Promise} Badge documents with isUnlocked flag
+ * Chief-awarded badges (awardedBadgeIds) are auto-unlocked.
*/
const getAllBadgesForUser = async (userId) => {
const [badges, acceptedSubmissions, allSubmissions, userObj] = await Promise.all([
- Badge.find().sort('rarity'),
+ Badge.find().sort({ rarity: 1, name: 1 }),
Submission.find({ userId, status: 'Accepted' }).populate('challengeId'),
Submission.find({ userId }).sort({ submittedAt: 1 }),
- User.findById(userId).select('createdAt')
+ User.findById(userId).select('createdAt awardedBadgeIds')
]);
if (!userObj) return [];
+ // Awarded badge IDs set (chief-granted)
+ const awardedIds = new Set((userObj.awardedBadgeIds || []).map(id => id.toString()));
+
// Group submissions by challenge
const challengeAttempts = {};
const firstSubmissions = {};
allSubmissions.forEach(sub => {
const chId = sub.challengeId?.toString();
if (!chId) return;
-
if (!challengeAttempts[chId]) {
challengeAttempts[chId] = { count: 0, rejectedCountBeforeAccept: 0, hasAccepted: false };
}
-
if (!challengeAttempts[chId].hasAccepted) {
challengeAttempts[chId].count++;
- if (sub.status === 'Rejected') {
- challengeAttempts[chId].rejectedCountBeforeAccept++;
- }
- if (sub.status === 'Accepted') {
- challengeAttempts[chId].hasAccepted = true;
- }
- }
-
- if (!firstSubmissions[chId]) {
- firstSubmissions[chId] = sub.status;
+ if (sub.status === 'Rejected') challengeAttempts[chId].rejectedCountBeforeAccept++;
+ if (sub.status === 'Accepted') challengeAttempts[chId].hasAccepted = true;
}
+ if (!firstSubmissions[chId]) firstSubmissions[chId] = sub.status;
});
- const flawlessCount = Object.values(firstSubmissions).filter(status => status === 'Accepted').length;
+ const flawlessCount = Object.values(firstSubmissions).filter(s => s === 'Accepted').length;
const maxRejectedBeforeAccept = Math.max(0, ...Object.values(challengeAttempts).filter(c => c.hasAccepted).map(c => c.rejectedCountBeforeAccept));
const hasFirstReject = allSubmissions.some(s => s.status === 'Rejected');
-
+
// Difficulty Breakdown
const diffCount = { Easy: 0, Medium: 0, Hard: 0 };
const languageCount = {};
-
- // To count unique accepted challenges
const uniqueAcceptedChIds = new Set();
acceptedSubmissions.forEach(sub => {
const chId = sub.challengeId?._id?.toString();
if (!chId || uniqueAcceptedChIds.has(chId)) return;
uniqueAcceptedChIds.add(chId);
-
const diff = sub.challengeId?.difficulty;
if (diff) diffCount[diff] = (diffCount[diff] || 0) + 1;
-
const lang = sub.language?.toLowerCase() || 'javascript';
languageCount[lang] = (languageCount[lang] || 0) + 1;
});
@@ -70,137 +59,194 @@ const getAllBadgesForUser = async (userId) => {
const totalUniqueAccepted = uniqueAcceptedChIds.size;
const polyglotCount = Object.keys(languageCount).length;
- // Streak & Heatmap calculation
+ // Streak & Heatmap
const heatmapAggregation = await Submission.aggregate([
{ $match: { userId: new mongoose.Types.ObjectId(userId), status: 'Accepted' } },
- {
- $group: {
- _id: { $dateToString: { format: '%Y-%m-%d', date: '$submittedAt' } },
- count: { $sum: 1 },
- },
- },
+ { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$submittedAt' } }, count: { $sum: 1 } } },
]);
const heatmapMap = {};
- heatmapAggregation.forEach((item) => { heatmapMap[item._id] = item.count; });
+ heatmapAggregation.forEach(item => { heatmapMap[item._id] = item.count; });
- let maxStreak = 0;
- let tempStreak = 0;
+ let maxStreak = 0, tempStreak = 0;
const joinDate = userObj.createdAt || new Date();
const startUTC = Date.UTC(joinDate.getUTCFullYear(), joinDate.getUTCMonth(), joinDate.getUTCDate());
- const todayUTC = new Date();
- const todayStr = todayUTC.toISOString().split('T')[0];
+ const todayStr = new Date().toISOString().split('T')[0];
const heatmapData = [];
+ let totalWeekendSolves = 0, maxSolvesInOneDay = 0;
for (let i = 0; i < 365; i++) {
- const d = new Date(startUTC + (i * 24 * 60 * 60 * 1000));
+ const d = new Date(startUTC + i * 86400000);
const dateStr = d.toISOString().split('T')[0];
- heatmapData.push({
- date: dateStr,
- count: dateStr > todayStr ? 0 : (heatmapMap[dateStr] || 0),
- isWeekend: d.getUTCDay() === 0 || d.getUTCDay() === 6
- });
+ const count = dateStr > todayStr ? 0 : (heatmapMap[dateStr] || 0);
+ heatmapData.push({ date: dateStr, count, isWeekend: d.getUTCDay() === 0 || d.getUTCDay() === 6 });
}
-
- let totalWeekendSolves = 0;
- let maxSolvesInOneDay = 0;
-
heatmapData.forEach(day => {
if (day.count > 0) {
tempStreak++;
if (tempStreak > maxStreak) maxStreak = tempStreak;
if (day.isWeekend) totalWeekendSolves += day.count;
- } else {
- tempStreak = 0;
- }
+ } else { tempStreak = 0; }
if (day.count > maxSolvesInOneDay) maxSolvesInOneDay = day.count;
});
const sortedAccepted = [...acceptedSubmissions].sort((a, b) => new Date(a.submittedAt) - new Date(b.submittedAt));
- // Time-based
- const nightOwlSubmission = sortedAccepted.find(s => {
- const hrs = new Date(s.submittedAt).getHours();
- return hrs >= 0 && hrs < 4;
- });
- const earlyBirdSubmission = sortedAccepted.find(s => {
- const hrs = new Date(s.submittedAt).getHours();
- return hrs >= 5 && hrs < 8;
- });
+ const nightOwlSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 0 && h < 4; });
+ const earlyBirdSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 5 && h < 8; });
+ const dawnCoderSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 4 && h < 5; });
- // Speedster check
let speedsterUnlocked = false;
for (let i = 1; i < sortedAccepted.length; i++) {
- const diffMins = (new Date(sortedAccepted[i].submittedAt) - new Date(sortedAccepted[i-1].submittedAt)) / (1000 * 60);
- if (diffMins <= 10) {
- speedsterUnlocked = true;
- break;
- }
+ const diffMins = (new Date(sortedAccepted[i].submittedAt) - new Date(sortedAccepted[i - 1].submittedAt)) / 60000;
+ if (diffMins <= 10) { speedsterUnlocked = true; break; }
}
- // Receive Feedback
const hasReviewee = allSubmissions.some(s => s.feedback && s.feedback.trim() !== '');
-
- // Perfectionist (100% acceptance rate with at least 10 accepted)
const perfectionistUnlocked = totalUniqueAccepted >= 10 && allSubmissions.every(s => s.status === 'Accepted');
+ // ============================================================
+ // UNLOCK MAP — 35+ badges
+ // ============================================================
const unlockedMap = {
// Milestones
- 'First Blood': totalUniqueAccepted >= 1,
- 'Code Novice': totalUniqueAccepted >= 10,
- 'Code Adept': totalUniqueAccepted >= 50,
- 'Algorithm Master': totalUniqueAccepted >= 100,
- 'Code Sensei': totalUniqueAccepted >= 150,
- 'Ascended': totalUniqueAccepted >= 250,
+ 'First Blood': totalUniqueAccepted >= 1,
+ 'Code Rookie': totalUniqueAccepted >= 5,
+ 'Code Novice': totalUniqueAccepted >= 10,
+ 'Challenger': totalUniqueAccepted >= 25,
+ 'Code Adept': totalUniqueAccepted >= 50,
+ 'Algorithm Master': totalUniqueAccepted >= 100,
+ 'Code Sensei': totalUniqueAccepted >= 150,
+ 'Ascended': totalUniqueAccepted >= 250,
+ 'The Legend': totalUniqueAccepted >= 500,
// Difficulty Mastery
- 'Easy Peasy': diffCount.Easy >= 10,
- 'Warmup Complete': diffCount.Easy >= 25,
- 'Stepping Up': diffCount.Medium >= 10,
- 'Midweight Champ': diffCount.Medium >= 25,
- 'Grandmaster': diffCount.Hard >= 1,
- 'Abyss Walker': diffCount.Hard >= 10,
-
- // Streak & Consistency
- 'Habit Builder': maxStreak >= 3,
- 'Streak Warrior': maxStreak >= 5,
- 'Unstoppable': maxStreak >= 14,
- 'Lunar Cycle': maxStreak >= 30,
- 'Weekend Warrior': totalWeekendSolves >= 5,
- 'Night Owl': !!nightOwlSubmission,
- 'Early Bird': !!earlyBirdSubmission,
+ 'Easy Peasy': diffCount.Easy >= 10,
+ 'Warmup Complete': diffCount.Easy >= 25,
+ 'Stepping Up': diffCount.Medium >= 10,
+ 'Midweight Champ': diffCount.Medium >= 25,
+ 'Grandmaster': diffCount.Hard >= 1,
+ 'Abyss Walker': diffCount.Hard >= 10,
+ 'Elite Solver': diffCount.Hard >= 25,
+
+ // Streaks & Consistency
+ 'Solo Sprint': maxStreak >= 1,
+ 'Habit Builder': maxStreak >= 3,
+ 'Streak Warrior': maxStreak >= 5,
+ 'Consistent Coder': maxStreak >= 7,
+ 'Unstoppable': maxStreak >= 14,
+ 'Lunar Cycle': maxStreak >= 30,
+ 'Night Owl': !!nightOwlSubmission,
+ 'Early Bird': !!earlyBirdSubmission,
+ 'Dawn Coder': !!dawnCoderSubmission,
+ 'Weekend Warrior': totalWeekendSolves >= 5,
// Precision & Accuracy
- 'Flawless': flawlessCount >= 1,
- 'Sharpshooter': flawlessCount >= 10,
- 'Eagle Eye': flawlessCount >= 50,
- 'Resilient': maxRejectedBeforeAccept >= 5,
- 'Never Surrender': maxRejectedBeforeAccept >= 10,
- 'Trial & Error': hasFirstReject,
+ 'Trial & Error': hasFirstReject,
+ 'Flawless': flawlessCount >= 1,
+ 'Resilient': maxRejectedBeforeAccept >= 5,
+ 'Sharpshooter': flawlessCount >= 10,
+ 'Never Surrender': maxRejectedBeforeAccept >= 10,
+ 'Comeback King': maxRejectedBeforeAccept >= 3,
+ 'Eagle Eye': flawlessCount >= 50,
// Language Diversity
- 'Polyglot': polyglotCount >= 3,
- 'JS Ninja': (languageCount['javascript'] || languageCount['js']) >= 10,
- 'Pythonista': (languageCount['python'] || languageCount['py']) >= 10,
- 'Java Juggernaut': languageCount['java'] >= 10,
- 'C/C++ Hacker': (languageCount['cpp'] || languageCount['c'] || languageCount['c++']) >= 10,
-
- // Miscellaneous
- 'Reviewee': hasReviewee,
- 'Perfectionist': perfectionistUnlocked,
- 'Speedster': speedsterUnlocked,
- 'Marathon': maxSolvesInOneDay >= 5
+ 'Polyglot': polyglotCount >= 3,
+ 'JS Ninja': (languageCount['javascript'] || languageCount['js'] || 0) >= 10,
+ 'Pythonista': (languageCount['python'] || languageCount['py'] || 0) >= 10,
+ 'Java Juggernaut': (languageCount['java'] || 0) >= 10,
+ 'C/C++ Hacker': (languageCount['cpp'] || languageCount['c'] || languageCount['c++'] || 0) >= 10,
+ 'Polyglot Pro': polyglotCount >= 5,
+
+ // Special
+ 'Reviewee': hasReviewee,
+ 'Perfectionist': perfectionistUnlocked,
+ 'Speedster': speedsterUnlocked,
+ 'Marathon': maxSolvesInOneDay >= 5,
+ 'Swift Fingers': maxSolvesInOneDay >= 10,
+ };
+
+ // ============================================================
+ // BADGE METADATA — category, progress, threshold, earnDifficulty
+ // ============================================================
+ const badgeMetadata = {
+ // Milestones
+ 'First Blood': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 1), threshold: 1, earnDifficulty: 'Easy' },
+ 'Code Rookie': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 5), threshold: 5, earnDifficulty: 'Easy' },
+ 'Code Novice': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 10), threshold: 10, earnDifficulty: 'Easy' },
+ 'Challenger': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 25), threshold: 25, earnDifficulty: 'Medium' },
+ 'Code Adept': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 50), threshold: 50, earnDifficulty: 'Medium' },
+ 'Algorithm Master': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 100), threshold: 100, earnDifficulty: 'Hard' },
+ 'Code Sensei': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 150), threshold: 150, earnDifficulty: 'Hard' },
+ 'Ascended': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 250), threshold: 250, earnDifficulty: 'Elite' },
+ 'The Legend': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 500), threshold: 500, earnDifficulty: 'Elite' },
+
+ // Difficulty Mastery
+ 'Easy Peasy': { category: 'Difficulty', progress: Math.min(diffCount.Easy, 10), threshold: 10, earnDifficulty: 'Easy' },
+ 'Warmup Complete': { category: 'Difficulty', progress: Math.min(diffCount.Easy, 25), threshold: 25, earnDifficulty: 'Medium' },
+ 'Stepping Up': { category: 'Difficulty', progress: Math.min(diffCount.Medium, 10), threshold: 10, earnDifficulty: 'Medium' },
+ 'Midweight Champ': { category: 'Difficulty', progress: Math.min(diffCount.Medium, 25), threshold: 25, earnDifficulty: 'Hard' },
+ 'Grandmaster': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 1), threshold: 1, earnDifficulty: 'Hard' },
+ 'Abyss Walker': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 10), threshold: 10, earnDifficulty: 'Elite' },
+ 'Elite Solver': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 25), threshold: 25, earnDifficulty: 'Elite' },
+
+ // Streaks
+ 'Solo Sprint': { category: 'Streaks', progress: Math.min(maxStreak, 1), threshold: 1, earnDifficulty: 'Easy' },
+ 'Habit Builder': { category: 'Streaks', progress: Math.min(maxStreak, 3), threshold: 3, earnDifficulty: 'Easy' },
+ 'Streak Warrior': { category: 'Streaks', progress: Math.min(maxStreak, 5), threshold: 5, earnDifficulty: 'Medium' },
+ 'Consistent Coder': { category: 'Streaks', progress: Math.min(maxStreak, 7), threshold: 7, earnDifficulty: 'Medium' },
+ 'Unstoppable': { category: 'Streaks', progress: Math.min(maxStreak, 14), threshold: 14, earnDifficulty: 'Hard' },
+ 'Lunar Cycle': { category: 'Streaks', progress: Math.min(maxStreak, 30), threshold: 30, earnDifficulty: 'Elite' },
+ 'Night Owl': { category: 'Streaks', progress: nightOwlSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' },
+ 'Early Bird': { category: 'Streaks', progress: earlyBirdSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' },
+ 'Dawn Coder': { category: 'Streaks', progress: dawnCoderSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Medium' },
+ 'Weekend Warrior': { category: 'Streaks', progress: Math.min(totalWeekendSolves, 5), threshold: 5, earnDifficulty: 'Medium' },
+
+ // Precision
+ 'Trial & Error': { category: 'Precision', progress: hasFirstReject ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' },
+ 'Flawless': { category: 'Precision', progress: Math.min(flawlessCount, 1), threshold: 1, earnDifficulty: 'Easy' },
+ 'Resilient': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 5), threshold: 5, earnDifficulty: 'Medium' },
+ 'Sharpshooter': { category: 'Precision', progress: Math.min(flawlessCount, 10), threshold: 10, earnDifficulty: 'Hard' },
+ 'Never Surrender': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 10), threshold: 10, earnDifficulty: 'Hard' },
+ 'Comeback King': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 3), threshold: 3, earnDifficulty: 'Medium' },
+ 'Eagle Eye': { category: 'Precision', progress: Math.min(flawlessCount, 50), threshold: 50, earnDifficulty: 'Elite' },
+
+ // Languages
+ 'Polyglot': { category: 'Languages', progress: Math.min(polyglotCount, 3), threshold: 3, earnDifficulty: 'Medium' },
+ 'JS Ninja': { category: 'Languages', progress: Math.min((languageCount['javascript'] || languageCount['js'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' },
+ 'Pythonista': { category: 'Languages', progress: Math.min((languageCount['python'] || languageCount['py'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' },
+ 'Java Juggernaut': { category: 'Languages', progress: Math.min((languageCount['java'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' },
+ 'C/C++ Hacker': { category: 'Languages', progress: Math.min((languageCount['cpp'] || languageCount['c'] || languageCount['c++'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' },
+ 'Polyglot Pro': { category: 'Languages', progress: Math.min(polyglotCount, 5), threshold: 5, earnDifficulty: 'Hard' },
+
+ // Special
+ 'Reviewee': { category: 'Special', progress: hasReviewee ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' },
+ 'Perfectionist': { category: 'Special', progress: perfectionistUnlocked ? 1 : 0, threshold: 1, earnDifficulty: 'Elite' },
+ 'Speedster': { category: 'Special', progress: speedsterUnlocked ? 1 : 0, threshold: 1, earnDifficulty: 'Hard' },
+ 'Marathon': { category: 'Special', progress: Math.min(maxSolvesInOneDay, 5), threshold: 5, earnDifficulty: 'Hard' },
+ 'Swift Fingers': { category: 'Special', progress: Math.min(maxSolvesInOneDay, 10),threshold: 10, earnDifficulty: 'Hard' },
};
return badges.map(badge => {
const badgeJson = badge.toJSON();
+ // Chief badges unlock via awardedBadgeIds
+ if (badge.isChiefBadge) {
+ badgeJson.isUnlocked = awardedIds.has(badge._id.toString());
+ badgeJson.category = "Chief's Choice";
+ badgeJson.progress = badgeJson.isUnlocked ? 1 : 0;
+ badgeJson.threshold = 1;
+ badgeJson.earnDifficulty = badge.earnDifficulty || 'Easy';
+ return badgeJson;
+ }
+
const isUnlocked = !!unlockedMap[badge.name];
+ const meta = badgeMetadata[badge.name] || { category: 'Special', progress: 0, threshold: 1, earnDifficulty: 'Medium' };
badgeJson.isUnlocked = isUnlocked;
- // We can just use current date for newly unlocked stuff since we don't store unlock dates persistently yet
- badgeJson.unlockedAt = isUnlocked ? new Date() : null;
+ badgeJson.category = meta.category;
+ badgeJson.progress = meta.progress;
+ badgeJson.threshold = meta.threshold;
+ badgeJson.earnDifficulty = badge.earnDifficulty || meta.earnDifficulty;
return badgeJson;
});
};
-module.exports = {
- getAllBadgesForUser
-};
+module.exports = { getAllBadgesForUser };
diff --git a/server/src/features/badges/seed.badges.js b/server/src/features/badges/seed.badges.js
new file mode 100644
index 0000000..89244c5
--- /dev/null
+++ b/server/src/features/badges/seed.badges.js
@@ -0,0 +1,93 @@
+/**
+ * Badge Seeder — Run once to populate the DB with all badges.
+ * Usage: node server/src/features/badges/seed.badges.js
+ */
+require('dotenv').config({ path: require('path').resolve(__dirname, '../../../../.env') });
+const mongoose = require('mongoose');
+const Badge = require('./Badge.model');
+
+const BADGES = [
+ // ── Milestones ──────────────────────────────────────────────
+ { name: 'First Blood', icon: '🩸', rarity: 'COMMON', color: 'red', earnDifficulty: 'Easy', description: 'Solved your very first challenge.' },
+ { name: 'Code Rookie', icon: '🌱', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '5 unique challenges solved.' },
+ { name: 'Code Novice', icon: '📗', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '10 unique challenges solved.' },
+ { name: 'Challenger', icon: '⚔️', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '25 unique challenges solved.' },
+ { name: 'Code Adept', icon: '🧠', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '50 unique challenges solved.' },
+ { name: 'Algorithm Master', icon: '🔮', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '100 unique challenges conquered.' },
+ { name: 'Code Sensei', icon: '🥷', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '150 unique challenges conquered.' },
+ { name: 'Ascended', icon: '🌌', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '250 challenges — you are legendary.' },
+ { name: 'The Legend', icon: '👑', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '500 challenges — true mastery achieved.' },
+
+ // ── Difficulty Mastery ───────────────────────────────────────
+ { name: 'Easy Peasy', icon: '🍃', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '10 Easy problems solved.' },
+ { name: 'Warmup Complete', icon: '🏃', rarity: 'COMMON', color: 'green', earnDifficulty: 'Medium', description: '25 Easy problems solved.' },
+ { name: 'Stepping Up', icon: '📈', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '10 Medium problems conquered.' },
+ { name: 'Midweight Champ', icon: '🥊', rarity: 'RARE', color: 'orange', earnDifficulty: 'Hard', description: '25 Medium problems conquered.' },
+ { name: 'Grandmaster', icon: '🏔️', rarity: 'EPIC', color: 'red', earnDifficulty: 'Hard', description: 'First Hard problem solved.' },
+ { name: 'Abyss Walker', icon: '🌑', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Elite', description: '10 Hard problems — into the abyss.' },
+ { name: 'Elite Solver', icon: '💎', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '25 Hard problems — true elite.' },
+
+ // ── Streaks & Consistency ────────────────────────────────────
+ { name: 'Solo Sprint', icon: '⚡', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'First daily solving streak.' },
+ { name: 'Habit Builder', icon: '📅', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: '3-day solving streak maintained.' },
+ { name: 'Streak Warrior', icon: '🔥', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: '5-day streak — fire is spreading.' },
+ { name: 'Consistent Coder', icon: '🗓️', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '7-day streak — a full week of coding.' },
+ { name: 'Unstoppable', icon: '🌊', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '14-day streak — nothing can stop you.' },
+ { name: 'Lunar Cycle', icon: '🌕', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '30-day streak — one full lunar cycle.' },
+ { name: 'Night Owl', icon: '🦉', rarity: 'RARE', color: 'purple', earnDifficulty: 'Easy', description: 'Solved between 12am – 4am.' },
+ { name: 'Dawn Coder', icon: '🌄', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: 'Solved between 4am – 5am.' },
+ { name: 'Early Bird', icon: '🐦', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'Solved between 5am – 8am.' },
+ { name: 'Weekend Warrior', icon: '🎮', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '5 problems solved on weekends.' },
+
+ // ── Precision & Accuracy ────────────────────────────────────
+ { name: 'Trial & Error', icon: '🔁', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'Your first rejected submission — learn from it.' },
+ { name: 'Flawless', icon: '✨', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: 'First-try acceptance on a problem.' },
+ { name: 'Comeback King', icon: '🔄', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: 'Accepted after 3+ rejections on the same problem.' },
+ { name: 'Resilient', icon: '🛡️', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: 'Accepted after 5+ rejections on the same problem.' },
+ { name: 'Sharpshooter', icon: '🎯', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '10 first-try accepted solutions.' },
+ { name: 'Never Surrender', icon: '💪', rarity: 'EPIC', color: 'red', earnDifficulty: 'Hard', description: 'Accepted after 10+ rejections.' },
+ { name: 'Eagle Eye', icon: '🦅', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '50 first-try accepted solutions.' },
+
+ // ── Language Diversity ───────────────────────────────────────
+ { name: 'Polyglot', icon: '🌐', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: 'Solved in 3 different languages.' },
+ { name: 'JS Ninja', icon: '🟨', rarity: 'RARE', color: 'gold', earnDifficulty: 'Medium', description: '10 problems solved in JavaScript.' },
+ { name: 'Pythonista', icon: '🐍', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '10 problems solved in Python.' },
+ { name: 'Java Juggernaut', icon: '☕', rarity: 'RARE', color: 'red', earnDifficulty: 'Medium', description: '10 problems solved in Java.' },
+ { name: 'C/C++ Hacker', icon: '⚙️', rarity: 'RARE', color: 'cyan', earnDifficulty: 'Medium', description: '10 problems solved in C or C++.' },
+ { name: 'Polyglot Pro', icon: '🗺️', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: 'Solved in 5 different languages.' },
+
+ // ── Special ──────────────────────────────────────────────────
+ { name: 'Reviewee', icon: '📝', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: 'Received feedback on a submission.' },
+ { name: 'Speedster', icon: '🚀', rarity: 'EPIC', color: 'orange', earnDifficulty: 'Hard', description: '2 different problems solved within 10 minutes.' },
+ { name: 'Marathon', icon: '🏅', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '5 problems solved in a single day.' },
+ { name: 'Swift Fingers', icon: '⌨️', rarity: 'EPIC', color: 'cyan', earnDifficulty: 'Hard', description: '10 problems solved in a single day.' },
+ { name: 'Perfectionist', icon: '💯', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '100% acceptance rate with 10+ accepted.' },
+
+ // ── Chief's Choice (manually awarded) ────────────────────────
+ { name: 'MVP', icon: '⭐', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Most Valuable Player — awarded by Clan Chief.' },
+ { name: 'Star Performer', icon: '🌟', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Outstanding performance recognized by the Clan Chief.' },
+ { name: 'Rising Star', icon: '🚀', rarity: 'RARE', color: 'blue', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Rapid growth and improvement — awarded by Clan Chief.' },
+ { name: 'Team Player', icon: '🤝', rarity: 'RARE', color: 'green', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Excellent collaboration and support within the clan.' },
+ { name: 'Honor Roll', icon: '🏆', rarity: 'EPIC', color: 'gold', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Consistent excellence recognized by Clan Chief.' },
+];
+
+async function seed() {
+ await mongoose.connect(process.env.MONGO_URI || process.env.DATABASE_URL);
+ console.log('Connected to DB');
+
+ let created = 0, updated = 0;
+ for (const badge of BADGES) {
+ const result = await Badge.findOneAndUpdate(
+ { name: badge.name },
+ { $set: badge },
+ { upsert: true, new: true, runValidators: true }
+ );
+ if (result.createdAt && result.updatedAt && result.createdAt.getTime() === result.updatedAt.getTime()) created++;
+ else updated++;
+ }
+
+ console.log(`✅ Seeded ${BADGES.length} badges — ${created} created, ${updated} updated`);
+ await mongoose.disconnect();
+}
+
+seed().catch(err => { console.error(err); process.exit(1); });
diff --git a/server/src/features/dashboard/dashboard.controller.js b/server/src/features/dashboard/dashboard.controller.js
index bb80f5f..5606aa9 100644
--- a/server/src/features/dashboard/dashboard.controller.js
+++ b/server/src/features/dashboard/dashboard.controller.js
@@ -229,9 +229,9 @@ const getUserProfile = async (req, res, next) => {
try {
let user;
if (req.params.userId) {
- user = await User.findById(req.params.userId).populate('clan', 'name tag');
+ user = await User.findById(req.params.userId).populate('clan', 'name tag').populate('featuredBadge');
} else if (req.params.username) {
- user = await User.findOne({ username: req.params.username }).populate('clan', 'name tag');
+ user = await User.findOne({ username: req.params.username }).populate('clan', 'name tag').populate('featuredBadge');
}
if (!user) {
@@ -547,11 +547,30 @@ const getPendingTasks = async (req, res, next) => {
}
};
+const updateFeaturedBadge = async (req, res, next) => {
+ try {
+ const { badgeId } = req.body;
+ if (!badgeId) {
+ const user = await User.findByIdAndUpdate(req.user.id, { featuredBadge: null }, { new: true }).populate('featuredBadge');
+ return sendSuccess(res, { data: user });
+ }
+ const unlockedBadges = await getAllBadgesForUser(req.user.id);
+ const hasBadge = unlockedBadges.some(b => b._id.toString() === badgeId.toString());
+ if (!hasBadge) {
+ return res.status(403).json({ success: false, message: 'You have not unlocked this badge.' });
+ }
+ const user = await User.findByIdAndUpdate(req.user.id, { featuredBadge: badgeId }, { new: true }).populate('featuredBadge');
+ return sendSuccess(res, { data: user, message: 'Featured badge updated successfully!' });
+ } catch (err) {
+ return next(err);
+ }
+};
+
module.exports = {
getDashboardSummary,
getProfileStats,
getUserProfile,
+ updateFeaturedBadge,
getAdminDashboardSummary,
getPendingTasks
};
-
diff --git a/server/src/features/profile/profile.routes.js b/server/src/features/profile/profile.routes.js
index faca3a2..624d50b 100644
--- a/server/src/features/profile/profile.routes.js
+++ b/server/src/features/profile/profile.routes.js
@@ -10,5 +10,7 @@ router.get('/stats', protect, getProfileStats);
router.get('/user/:userId', protect, getUserProfile);
router.get('/username/:username', getUserProfile);
+router.put('/featured-badge', protect, require('../dashboard/dashboard.controller').updateFeaturedBadge);
+
module.exports = router;
diff --git a/server/src/features/submissions/submission.controller.js b/server/src/features/submissions/submission.controller.js
index b2c91fe..40f0898 100644
--- a/server/src/features/submissions/submission.controller.js
+++ b/server/src/features/submissions/submission.controller.js
@@ -424,14 +424,24 @@ const updateSubmissionStatus = async (req, res, next) => {
if (userToUpdate) {
userToUpdate.points = Math.max(0, (userToUpdate.points || 0) + pointsDiff);
userToUpdate.solvedProblems = Math.max(0, (userToUpdate.solvedProblems || 0) + solvedDiff);
-
- if (userToUpdate.solvedProblems >= 30) {
- userToUpdate.codingLevel = 'Advanced';
- } else if (userToUpdate.solvedProblems >= 10) {
- userToUpdate.codingLevel = 'Intermediate';
- } else {
- userToUpdate.codingLevel = 'Beginner';
+
+ // Auto-progress coding level unless chief has manually overridden it
+ if (!userToUpdate.codingLevelOverridden) {
+ if (userToUpdate.solvedProblems >= 75) {
+ userToUpdate.codingLevel = 'Advanced';
+ } else if (userToUpdate.solvedProblems >= 25) {
+ userToUpdate.codingLevel = 'Intermediate';
+ } else {
+ userToUpdate.codingLevel = 'Beginner';
+ }
+ }
+
+ // Auto-clear warning when user gets an Accepted submission
+ if (isNowAccepted && userToUpdate.status === 'Warned') {
+ userToUpdate.status = 'Active';
+ userToUpdate.warningMessage = null;
}
+
await userToUpdate.save();
const XpLog = require('../users/XpLog.model');
diff --git a/server/src/features/users/User.model.js b/server/src/features/users/User.model.js
index f34d51c..2cbf890 100644
--- a/server/src/features/users/User.model.js
+++ b/server/src/features/users/User.model.js
@@ -74,6 +74,10 @@ const userSchema = new mongoose.Schema({
enum: ['Beginner', 'Intermediate', 'Advanced'],
default: 'Beginner',
},
+ codingLevelOverridden: {
+ type: Boolean,
+ default: false,
+ },
preferredLanguage: {
type: String,
enum: ['javascript', 'python', 'java', 'cpp', 'c'],
@@ -129,8 +133,10 @@ const userSchema = new mongoose.Schema({
location: { type: String, default: '' },
github: { type: String, default: '' },
twitter: { type: String, default: '' },
+ awardedBadgeIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Badge' }],
linkedin: { type: String, default: '' },
website: { type: String, default: '' },
+ featuredBadge: { type: mongoose.Schema.Types.ObjectId, ref: 'Badge', default: null },
});
// Partial unique indexes: only documents where the field is a string are
diff --git a/server/src/features/users/user.controller.js b/server/src/features/users/user.controller.js
index d818789..a443f08 100644
--- a/server/src/features/users/user.controller.js
+++ b/server/src/features/users/user.controller.js
@@ -90,7 +90,7 @@ const updateUserRole = async (req, res, next) => {
// @access Private/Chief/Admin
const updateUserLevel = async (req, res, next) => {
try {
- const { level } = req.body;
+ const { level, clearOverride } = req.body;
if (!['Beginner', 'Intermediate', 'Advanced'].includes(level)) {
return res.status(400).json({ success: false, message: 'Invalid level' });
}
@@ -104,6 +104,8 @@ const updateUserLevel = async (req, res, next) => {
}
user.codingLevel = level;
+ // Mark as manually overridden unless explicitly clearing the override
+ user.codingLevelOverridden = clearOverride ? false : true;
await user.save();
return sendSuccess(res, { data: user, message: 'User level updated' });