Skip to content
Merged
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
331 changes: 270 additions & 61 deletions backend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"node-cron": "^4.2.1",
"nodemailer": "^9.0.5",
"pdf-parse": "1.1.1",
"socket.io": "^4.8.3",
"xss": "^1.0.15",
"zod": "^4.3.6"
},
Expand Down
6 changes: 6 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const gdprRoutes = require("./routes/gdprRoutes");
const cronRoutes = require("./routes/cronRoutes");
const aiRoutes = require("./routes/aiRoutes");
const studyProfileRoutes = require("./routes/studyProfileRoutes");
const roomRoutes = require("./routes/roomRoutes");
const xpRoutes = require("./routes/xpRoutes");
const leaderboardRoutes = require("./routes/leaderboardRoutes");

const app = express();
app.set("trust proxy", 1);
Expand Down Expand Up @@ -102,12 +105,15 @@ app.use("/api/llm", llmRoutes);
app.use("/api/seed", seedRoutes);
app.use("/api/feedback", feedbackRoutes);
app.use("/api/gdpr", gdprRoutes);
app.use("/api/rooms", roomRoutes);
app.use("/api/admin", require("./admin/routes/adminRoutes"));
// Cron endpoint: called by Vercel Cron in production;
// node-cron handles the same job on traditional/local servers (see server.js).
app.use("/api/cron", cronRoutes);
app.use("/api/ai", aiRoutes);
app.use("/api/study-profile", studyProfileRoutes);
app.use("/api/xp", xpRoutes);
app.use("/api/leaderboard", leaderboardRoutes);

app.get(["/favicon.ico", "/favicon.png"], (req, res) => res.status(204).end());

Expand Down
67 changes: 67 additions & 0 deletions backend/src/controllers/leaderboardController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const leaderboardService = require("../services/leaderboardService");

/**
* GET /api/leaderboard
* Query params: period (weekly | monthly | alltime), stream (global | engineering | medical | commerce | competitive)
*/
const getLeaderboard = async (req, res) => {
try {
const { period = "weekly", stream = "global", limit = 50 } = req.query;
const currentUserId = req.user?._id;

const data = await leaderboardService.getLeaderboard({
period,
stream,
limit: parseInt(limit, 10) || 50,
currentUserId,
});

res.status(200).json({
success: true,
data,
});
} catch (error) {
console.error("Error fetching leaderboard:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch leaderboard",
});
}
};

/**
* GET /api/leaderboard/user
* Fetch user's rank across periods and streams
*/
const getUserRank = async (req, res) => {
try {
const currentUserId = req.user._id;

const [weeklyGlobal, monthlyGlobal, weeklyStream] = await Promise.all([
leaderboardService.getLeaderboard({ period: "weekly", stream: "global", currentUserId }),
leaderboardService.getLeaderboard({ period: "monthly", stream: "global", currentUserId }),
leaderboardService.getLeaderboard({ period: "weekly", stream: req.user.stream || req.user.studyStream || "engineering", currentUserId }),
]);

res.status(200).json({
success: true,
data: {
weeklyGlobal: weeklyGlobal.userRank,
monthlyGlobal: monthlyGlobal.userRank,
weeklyStream: weeklyStream.userRank,
stream: req.user.stream || req.user.studyStream || "engineering",
},
});
} catch (error) {
console.error("Error fetching user rank:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch user rank",
});
}
};

module.exports = {
getLeaderboard,
getUserRank,
};
123 changes: 123 additions & 0 deletions backend/src/controllers/roomController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const asyncHandler = require("express-async-handler");
const FocusRoom = require("../models/FocusRoom");

// @desc Get all public or stream focus rooms
// @route GET /api/rooms
// @access Private
const getRooms = asyncHandler(async (req, res) => {
const { stream, visibility } = req.query;
const filter = { status: { $ne: "closed" } };

if (stream && stream !== "all") {
filter.stream = stream;
}

if (visibility) {
filter.visibility = visibility;
} else {
filter.visibility = { $in: ["public", "stream"] };
}

const rooms = await FocusRoom.find(filter)
.populate("host", "name picture")
.populate("participants.user", "name picture")
.sort({ createdAt: -1 });

res.status(200).json(rooms);
});

// @desc Create a new focus room
// @route POST /api/rooms
// @access Private
const createRoom = asyncHandler(async (req, res) => {
const { name, description, maxParticipants, visibility, stream, timerConfig, ambientPreset } = req.body;

if (!name || !name.trim()) {
res.status(400);
throw new Error("Room name is required");
}

const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now().toString(36)}`;
const inviteCode = visibility === "private" ? Math.random().toString(36).substring(2, 8).toUpperCase() : undefined;

const room = await FocusRoom.create({
name: name.trim(),
slug,
description: description ? description.trim() : "",
host: req.user._id,
maxParticipants: maxParticipants || 25,
visibility: visibility || "public",
stream: stream || null,
inviteCode,
timerConfig: timerConfig || {
focusDuration: 25,
shortBreakDuration: 5,
longBreakDuration: 15,
autoStart: false,
},
ambientPreset: ambientPreset || "none",
participants: [
{
user: req.user._id,
status: "idle",
},
],
});

const populatedRoom = await FocusRoom.findById(room._id)
.populate("host", "name picture")
.populate("participants.user", "name picture");

res.status(201).json(populatedRoom);
});

// @desc Get single focus room by ID or Slug
// @route GET /api/rooms/:id
// @access Private
const getRoomById = asyncHandler(async (req, res) => {
let room = await FocusRoom.findById(req.params.id)
.populate("host", "name picture")
.populate("participants.user", "name picture xp badges");

if (!room) {
room = await FocusRoom.findOne({ slug: req.params.id })
.populate("host", "name picture")
.populate("participants.user", "name picture xp badges");
}

if (!room) {
res.status(404);
throw new Error("Focus room not found");
}

res.status(200).json(room);
});

// @desc Close / Delete a focus room (Host only)
// @route DELETE /api/rooms/:id
// @access Private
const closeRoom = asyncHandler(async (req, res) => {
const room = await FocusRoom.findById(req.params.id);

if (!room) {
res.status(404);
throw new Error("Focus room not found");
}

if (room.host.toString() !== req.user._id.toString()) {
res.status(403);
throw new Error("Only the room host can close this room");
}

room.status = "closed";
await room.save();

res.status(200).json({ message: "Room closed successfully", roomId: room._id });
});

module.exports = {
getRooms,
createRoom,
getRoomById,
closeRoom,
};
22 changes: 18 additions & 4 deletions backend/src/controllers/sessionController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const asyncHandler = require("express-async-handler");
const Session = require("../models/Session");
const User = require("../models/User");
const Task = require("../models/Task");
const { awardXP } = require("../services/xpService");

// @desc Log a completed session
// @route POST /api/sessions
Expand All @@ -19,10 +20,16 @@ const createSession = asyncHandler(async (req, res) => {
mood,
});

let xpResult = null;

if (type === "focus") {
const user = await User.findById(req.user._id);
user.points += 10;
await user.save();
// Calculate session total pomodoros for badges
const userSessions = await Session.countDocuments({ user: req.user._id, type: "focus" });
xpResult = await awardXP(req.user._id, 10, "solo_focus_session", {
totalPomodoroCount: userSessions,
dailyFocusMinutes: (duration || 25),
});

if (task) {
const taskDoc = await Task.findById(task);
if (taskDoc) {
Expand All @@ -32,7 +39,14 @@ const createSession = asyncHandler(async (req, res) => {
}
}

res.status(201).json(session);
res.status(201).json({
...session.toObject(),
xpEarned: xpResult ? xpResult.xpEarned : 0,
totalXP: xpResult ? xpResult.totalXP : 0,
level: xpResult ? xpResult.level : 1,
leveledUp: xpResult ? xpResult.leveledUp : false,
newlyEarnedBadges: xpResult ? xpResult.newlyEarnedBadges : [],
});
});

// @desc Get user sessions
Expand Down
81 changes: 81 additions & 0 deletions backend/src/controllers/xpController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const asyncHandler = require("express-async-handler");
const User = require("../models/User");
const { ALL_BADGES } = require("../services/badgeService");
const { calculateLevel, getXPForLevel, activateStreakShield } = require("../services/xpService");

// @desc Get user XP and gamification summary
// @route GET /api/xp/me
// @access Private
const getXpSummary = asyncHandler(async (req, res) => {
const user = await User.findById(req.user._id).select("xp points currentStreak longestStreak streakShield earnedBadges badges");

if (!user) {
res.status(404);
throw new Error("User not found");
}

const totalXP = user.xp?.total || 0;
const currentLevel = user.xp?.level || calculateLevel(totalXP);
const currentLevelXPThreshold = getXPForLevel(currentLevel);
const nextLevelXPThreshold = getXPForLevel(currentLevel + 1);
const levelXPProgress = Math.max(0, totalXP - currentLevelXPThreshold);
const levelXPRequired = Math.max(1, nextLevelXPThreshold - currentLevelXPThreshold);
const progressPercent = Math.min(100, Math.round((levelXPProgress / levelXPRequired) * 100));

res.status(200).json({
totalXP,
weeklyXP: user.xp?.weekly || 0,
monthlyXP: user.xp?.monthly || 0,
level: currentLevel,
currentLevelXPThreshold,
nextLevelXPThreshold,
progressPercent,
currentStreak: user.currentStreak || 0,
longestStreak: user.longestStreak || 0,
streakShield: user.streakShield || { active: false },
earnedBadgesCount: user.earnedBadges?.length || 0,
totalBadgesCount: ALL_BADGES.length,
});
});

// @desc Get user badge shelf
// @route GET /api/xp/badges
// @access Private
const getBadges = asyncHandler(async (req, res) => {
const user = await User.findById(req.user._id).select("earnedBadges badges");
const earnedMap = new Map();

if (user && user.earnedBadges) {
user.earnedBadges.forEach((b) => {
earnedMap.set(b.id, b);
});
}

const badgeShelf = ALL_BADGES.map((badge) => {
const earned = earnedMap.get(badge.id);
return {
...badge,
unlocked: !!earned,
earnedAt: earned ? earned.earnedAt : null,
};
});

res.status(200).json(badgeShelf);
});

// @desc Activate a streak shield
// @route POST /api/xp/streak-shield
// @access Private
const toggleStreakShield = asyncHandler(async (req, res) => {
const streakShield = await activateStreakShield(req.user._id);
res.status(200).json({
message: "Streak shield activated successfully!",
streakShield,
});
});

module.exports = {
getXpSummary,
getBadges,
toggleStreakShield,
};
19 changes: 18 additions & 1 deletion backend/src/middleware/authMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,21 @@ const protect = asyncHandler(async (req, res, next) => {
}
});

module.exports = { protect };
const optionalAuth = asyncHandler(async (req, res, next) => {
let token = req.cookies.jwt;
if (!token && req.headers.authorization && req.headers.authorization.startsWith("Bearer")) {
token = req.headers.authorization.split(" ")[1];
}

if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.userId).select("-password");
} catch (e) {
// Ignore token failure for optional auth
}
}
next();
});

module.exports = { protect, optionalAuth };
Loading
Loading