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
3 changes: 3 additions & 0 deletions backend/constants/achievements.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const VALID_ACHIEVEMENTS = new Set([
"Resume Expert",
"DSA Beginner",
"DSA Master",
"3-Day Streak",
"7-Day Streak",
"30-Day Streak",
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep streak badges out of the client-writable allowlist.

saveAchievements accepts every value in VALID_ACHIEVEMENTS. A client can submit "3-Day Streak", "7-Day Streak", or "30-Day Streak" without completing the required streak.

Reject server-managed streak badges in saveAchievements, or remove these identifiers from VALID_ACHIEVEMENTS. Keep sessionController as the only writer for these badges.

🤖 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/constants/achievements.js` around lines 13 - 15, Remove "3-Day
Streak", "7-Day Streak", and "30-Day Streak" from VALID_ACHIEVEMENTS, or
explicitly reject them in saveAchievements, so clients cannot write
server-managed streak badges. Preserve sessionController as the only code path
that awards these identifiers.

]);

module.exports = { VALID_ACHIEVEMENTS };
16 changes: 15 additions & 1 deletion backend/controllers/achievementController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ const { VALID_ACHIEVEMENTS } = require('../constants/achievements');

exports.getAchievements = async (req, res) => {
try {
const user = await User.findById(req.user._id).select('unlockedAchievements');
const user = await User.findById(req.user._id).select('unlockedAchievements lastPracticeDate currentStreak');
if (!user) return res.status(404).json({ success: false, error: 'User not found' });

// Reset streak to 0 if one or more calendar days were missed
if (user.lastPracticeDate && user.currentStreak > 0) {
const now = new Date();
const d1 = new Date(user.lastPracticeDate);
const utc1 = Date.UTC(d1.getUTCFullYear(), d1.getUTCMonth(), d1.getUTCDate());
const utc2 = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const diffDays = Math.floor((utc2 - utc1) / (1000 * 60 * 60 * 24));
if (diffDays > 1) {
user.currentStreak = 0;
await user.save();
Comment on lines +16 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the affected handlers and Mongoose concurrency configuration.
ast-grep outline backend/controllers/achievementController.js --items all
ast-grep outline backend/controllers/authController.js --items all
ast-grep outline backend/models/User.js --items all

# Expect no custom protection to be required after the reset becomes conditional.
rg -n -C 3 'optimisticConcurrency|versionKey|pre\(|post\(|currentStreak|lastPracticeDate' \
  backend/controllers/achievementController.js \
  backend/controllers/authController.js \
  backend/controllers/sessionController.js \
  backend/models/User.js

Repository: Canopus-Labs/PrepPilot

Length of output: 8050


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- achievement handler ---'
sed -n '1,80p' backend/controllers/achievementController.js

printf '%s\n' '--- profile handler ---'
sed -n '390,455p' backend/controllers/authController.js

printf '%s\n' '--- session write path ---'
sed -n '70,155p' backend/controllers/sessionController.js

printf '%s\n' '--- User schema ---'
sed -n '1,110p' backend/models/User.js

printf '%s\n' '--- dependency and schema configuration ---'
rg -n -C 2 '"mongoose"|optimisticConcurrency|versionKey|findOneAndUpdate|findByIdAndUpdate|save\(' \
  package.json backend package-lock.json yarn.lock 2>/dev/null || true

Repository: Canopus-Labs/PrepPilot

Length of output: 35642


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- authentication middleware ---'
sed -n '1,180p' backend/middlewares/authMiddleware.js

printf '%s\n' '--- routes for the affected handlers ---'
rg -n -C 4 'getUserProfile|getAchievements|createSession|profile|achievements' \
  backend/routes backend/controllers backend/middlewares

printf '%s\n' '--- relevant controller tests ---'
sed -n '1,240p' backend/tests/achievementController.unit.test.js
rg -n -C 5 'getUserProfile|currentStreak|lastPracticeDate|createSession' backend/tests

Repository: Canopus-Labs/PrepPilot

Length of output: 27972


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

achievement = Path("backend/controllers/achievementController.js").read_text()
auth = Path("backend/controllers/authController.js").read_text()
session = Path("backend/controllers/sessionController.js").read_text()
user = Path("backend/models/User.js").read_text()

checks = {
    "achievement reads then saves reset": re.search(
        r"findById\(req\.user\._id\).*?currentStreak.*?user\.currentStreak\s*=\s*0;\s*await user\.save\(\)",
        achievement, re.S
    ),
    "profile uses middleware-loaded document": re.search(
        r"const user = req\.user;.*?user\.currentStreak\s*=\s*0;\s*await user\.save\(\)",
        auth, re.S
    ),
    "session updates streak and practice date": re.search(
        r"user\.currentStreak.*?user\.lastPracticeDate\s*=\s*now;.*?await user\.save",
        session, re.S
    ),
    "User schema has no optimisticConcurrency": not re.search(
        r"optimisticConcurrency\s*:\s*true", user
    ),
}

for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)

print("Both reset handlers can issue an unconditional save after a stale read.")
print("The session path can write a new currentStreak and lastPracticeDate between that read and save.")
PY

Repository: Canopus-Labs/PrepPilot

Length of output: 509


Prevent stale streak resets from overwriting new practice data.

Use an atomic conditional update in both handlers. Match the user only when lastPracticeDate is still before the UTC yesterday boundary. In getUserProfile, reload the user before res.json(user) so a skipped reset returns the current streak.

📍 Affects 2 files
  • backend/controllers/achievementController.js#L16-L18 (this comment)
  • backend/controllers/authController.js#L412-L414
🤖 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/controllers/achievementController.js` around lines 16 - 18, Replace
the direct streak reset and save in both achievementController.js (lines 16-18)
and authController.js (lines 412-414) with an atomic conditional update that
matches the user only when lastPracticeDate remains before the UTC yesterday
boundary. In getUserProfile, reload the user after the conditional update and
before res.json(user) so skipped resets return the latest streak.

}
}

res.json({ success: true, unlockedAchievements: user.unlockedAchievements });
} catch (err) {
res.status(500).json({ success: false, error: "A server error occurred" });
Expand Down
14 changes: 14 additions & 0 deletions backend/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,20 @@ const getUserProfile = async (req, res) => {
if (!user) {
return res.status(404).json({ success: false, message: "Requested user profile not found" });
}

// Reset streak to 0 if one or more calendar days were missed
if (user.lastPracticeDate && user.currentStreak > 0) {
const now = new Date();
const d1 = new Date(user.lastPracticeDate);
const utc1 = Date.UTC(d1.getUTCFullYear(), d1.getUTCMonth(), d1.getUTCDate());
const utc2 = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const diffDays = Math.floor((utc2 - utc1) / (1000 * 60 * 60 * 24));
if (diffDays > 1) {
user.currentStreak = 0;
await user.save();
}
}

res.json(user);
} catch (error) {
console.error("Get profile error:", error);
Expand Down
47 changes: 47 additions & 0 deletions backend/controllers/sessionController.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Session = require("../models/Session");
const Question = require("../models/Question");
const mongoose = require("mongoose");
const User = require("../models/User");


const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 50;
Expand Down Expand Up @@ -92,6 +93,52 @@ await createdSession[0].save({
session: mongoSession,
});

// Update user streak on successful session creation
const user = await User.findById(userId).session(mongoSession);
if (user) {
const now = new Date();
const getUTCDayDifference = (date1, date2) => {
const d1 = new Date(date1);
const d2 = new Date(date2);
const utc1 = Date.UTC(d1.getUTCFullYear(), d1.getUTCMonth(), d1.getUTCDate());
const utc2 = Date.UTC(d2.getUTCFullYear(), d2.getUTCMonth(), d2.getUTCDate());
const msPerDay = 1000 * 60 * 60 * 24;
return Math.floor((utc2 - utc1) / msPerDay);
};

if (!user.lastPracticeDate) {
user.currentStreak = 1;
} else {
const diff = getUTCDayDifference(user.lastPracticeDate, now);
if (diff === 1) {
user.currentStreak += 1;
} else if (diff > 1) {
user.currentStreak = 1;
} // if diff === 0, keep current streak (already practiced today)
}

user.lastPracticeDate = now;

if (user.currentStreak > (user.longestStreak || 0)) {
user.longestStreak = user.currentStreak;
}

// Check milestone badges
const streakMilestones = [
{ days: 3, badge: "3-Day Streak" },
{ days: 7, badge: "7-Day Streak" },
{ days: 30, badge: "30-Day Streak" }
];

for (const milestone of streakMilestones) {
if (user.currentStreak >= milestone.days && !user.unlockedAchievements.includes(milestone.badge)) {
user.unlockedAchievements.push(milestone.badge);
}
}

await user.save({ session: mongoSession });
}

res.status(201).json({
success: true,
session: createdSession[0],
Expand Down
5 changes: 5 additions & 0 deletions backend/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const UserSchema = new mongoose.Schema(

unlockedAchievements: { type: [String], default: [] },

// Streak Tracking
currentStreak: { type: Number, default: 0 },
longestStreak: { type: Number, default: 0 },
lastPracticeDate: { type: Date, default: null },

//Email Verification
isEmailVerified: { type: Boolean, default: false },
emailVerificationToken: { type: String, default: null },
Expand Down
Loading