diff --git a/.github/workflows/issue-claim-bot.yml b/.github/workflows/issue-claim-bot.yml new file mode 100644 index 00000000..fe5a3a5e --- /dev/null +++ b/.github/workflows/issue-claim-bot.yml @@ -0,0 +1,167 @@ +# ───────────────────────────────────────────────────────────────────────────── +# issue-claim-bot.yml +# +# PURPOSE: +# • Posts a welcome comment when a new Issue is opened, inviting contributors +# to claim it with /claim. +# • Handles /claim comments: assigns the issue, adds a label, and replies. +# • Prevents duplicate claims and handles all edge cases gracefully. +# +# CUSTOMISATION: +# CLAIMED_LABEL — label added when an issue is claimed (created if missing) +# EXEMPT_USERS — comma-separated GitHub usernames who are always allowed +# ───────────────────────────────────────────────────────────────────────────── +name: Issue Claim Bot + +on: + issues: + types: [opened] + issue_comment: + types: [created] # only new comments; ignore edits/deletions + +permissions: + issues: write + contents: read + +env: + CLAIMED_LABEL: "claimed" + +jobs: + # ── JOB 1: Welcome message when a new issue is opened ────────────────────── + welcome: + name: Post welcome comment + runs-on: ubuntu-latest + if: github.event_name == 'issues' && github.event.action == 'opened' + + steps: + - name: Comment welcome message + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issueNumber = context.payload.issue.number; + + await github.rest.issues.createComment({ + owner, repo, + issue_number: issueNumber, + body: + `Thanks for creating this issue!\n\n` + + `If you'd like to work on it, simply comment:\n\n` + + `> /claim\n\n` + + `The **first** person to claim the issue will be assigned automatically.\n\n` + + `Happy contributing!`, + }); + core.info(`Welcome comment posted on #${issueNumber}`); + + # ── JOB 2: Handle /claim comments ───────────────────────────────────────── + handle-claim: + name: Handle /claim + runs-on: ubuntu-latest + # Only run on issue_comment events (not issue opened) + if: github.event_name == 'issue_comment' && github.event.action == 'created' + + steps: + - name: Process /claim command + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const comment = context.payload.comment; + const issue = context.payload.issue; + const actor = comment.user.login; + const LABEL = process.env.CLAIMED_LABEL; + + // ── Guard: case-insensitive /claim check (trim whitespace) ─────── + const body = comment.body.trim().toLowerCase(); + if (body !== "/claim") { + core.info("Comment is not '/claim' — ignoring."); + return; + } + + core.info(`/claim received on #${issue.number} from @${actor}`); + + // ── Guard: skip bots ────────────────────────────────────────────── + if (comment.user.type === "Bot" || actor.endsWith("[bot]")) { + core.info("Commenter is a bot — ignoring."); + return; + } + + // ── Guard: skip if this is a PR (issue_comment fires on PRs too) ─ + if (issue.pull_request) { + core.info("This is a PR comment, not an issue — ignoring."); + return; + } + + // ── Guard: skip closed issues ──────────────────────────────────── + if (issue.state === "closed") { + await github.rest.issues.createComment({ + owner, repo, + issue_number: issue.number, + body: `⚠️ @${actor} — This issue is already **closed** and cannot be claimed.`, + }); + return; + } + + // ── Check existing assignees ────────────────────────────────────── + const assignees = (issue.assignees || []).map(a => a.login); + + if (assignees.length > 0) { + // Already claimed — inform the commenter + const claimedBy = assignees[0]; + await github.rest.issues.createComment({ + owner, repo, + issue_number: issue.number, + body: + `❌ This issue has already been claimed by **@${claimedBy}**.\n\n` + + `Please look for another open issue or create a new one to contribute.\n\n` + + `Thank you for your interest!`, + }); + core.info(`Issue #${issue.number} already assigned to @${claimedBy} — notified @${actor}.`); + return; + } + + // ── Assign issue to the claimant ───────────────────────────────── + await github.rest.issues.addAssignees({ + owner, repo, + issue_number: issue.number, + assignees: [actor], + }); + + // ── Ensure 'claimed' label exists (create if missing) ───────────── + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (err) { + if (err.status === 404) { + await github.rest.issues.createLabel({ + owner, repo, + name: LABEL, + color: "0075ca", // blue + description: "This issue has been claimed by a contributor", + }); + core.info(`Created label '${LABEL}'`); + } else { + throw err; + } + } + + // ── Add 'claimed' label ─────────────────────────────────────────── + await github.rest.issues.addLabels({ + owner, repo, + issue_number: issue.number, + labels: [LABEL], + }); + + // ── Post success comment ────────────────────────────────────────── + await github.rest.issues.createComment({ + owner, repo, + issue_number: issue.number, + body: + `✅ **@${actor}** has successfully claimed this issue!\n\n` + + `You've been assigned. Happy coding!\n\n` + + `> **Tip:** Please open a Pull Request linked to this issue when you're ready.\n` + + `> Add \`Closes #${issue.number}\` in your PR description.`, + }); + + core.info(`Issue #${issue.number} successfully claimed by @${actor}.`); diff --git a/.github/workflows/rate-limit-contributions.yml b/.github/workflows/rate-limit-contributions.yml new file mode 100644 index 00000000..33d78d44 --- /dev/null +++ b/.github/workflows/rate-limit-contributions.yml @@ -0,0 +1,174 @@ +# ───────────────────────────────────────────────────────────────────────────── +# rate-limit-contributions.yml +# +# PURPOSE: +# Prevents repository spam by auto-closing any new Issue or Pull Request +# from a contributor who already has 3 or more open submissions of that type. +# +# CUSTOMISATION: +# Change the MAX_OPEN_ISSUES and MAX_OPEN_PRS env vars below to adjust limits. +# Admins / maintainers / collaborators are always exempt. +# ───────────────────────────────────────────────────────────────────────────── +name: Rate-limit contributions + +on: + issues: + types: [opened] + pull_request_target: # use pull_request_target so GITHUB_TOKEN has write perms on forks + types: [opened] + +# Minimal, scoped permissions +permissions: + issues: write + pull-requests: write + contents: read + +env: + # ── Tune these values to change the limits ────────────────────────────────── + MAX_OPEN_ISSUES: 3 # max simultaneous open Issues per contributor + MAX_OPEN_PRS: 3 # max simultaneous open PRs per contributor + RATE_LIMITED_LABEL: "rate-limited" + +jobs: + check-rate-limit: + runs-on: ubuntu-latest + # Skip the check entirely for bots (dependabot, renovate, etc.) + if: ${{ !contains(github.actor, '[bot]') }} + + steps: + - name: Enforce contribution rate limit + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // ── Config ───────────────────────────────────────────────────── + const MAX_OPEN_ISSUES = parseInt(process.env.MAX_OPEN_ISSUES, 10); + const MAX_OPEN_PRS = parseInt(process.env.MAX_OPEN_PRS, 10); + const LABEL = process.env.RATE_LIMITED_LABEL; + + // ── Excluded contributors (always exempt, regardless of role) ─── + const EXCLUDED_USERS = [ + "jainiksha", // project lead / trusted contributor + ]; + + const CLOSE_MESSAGE = + `Thank you for your contribution!\n\n` + + `To keep reviews manageable and maintain repository quality, ` + + `contributors may have a maximum of **${MAX_OPEN_ISSUES} open Issues** ` + + `and **${MAX_OPEN_PRS} open Pull Requests** at any given time.\n\n` + + `Please wait until one of your existing submissions is reviewed or ` + + `closed before opening additional ones.\n\n` + + `If you believe this was closed by mistake, feel free to contact the maintainers.`; + + const { repo: { owner, repo }, actor } = context; + const isIssue = context.eventName === "issues"; + const itemNumber = isIssue + ? context.payload.issue.number + : context.payload.pull_request.number; + + core.info(`Event: ${context.eventName} | Author: ${actor} | #${itemNumber}`); + + // ── 0. Exempt explicitly excluded users ───────────────────────── + if (EXCLUDED_USERS.map(u => u.toLowerCase()).includes(actor.toLowerCase())) { + core.info(`${actor} is in the exclusion list — skipping rate limit.`); + return; + } + + // ── 1. Exempt specific contributors, repo owner, admins and collaborators ── + const EXEMPT_USERS = ["jainiksha"]; // add more usernames here if needed + if (EXEMPT_USERS.map(u => u.toLowerCase()).includes(actor.toLowerCase())) { + core.info(`${actor} is in the exempt list — skipping rate limit.`); + return; + } + + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: actor, + }); + // permission levels: admin | maintain | write | triage | read + if (["admin", "maintain", "write"].includes(perm.permission)) { + core.info(`${actor} has '${perm.permission}' permission — skipping rate limit.`); + return; + } + } catch (err) { + // 404 = not a collaborator; any other error → log but continue + if (err.status !== 404) core.warning(`Permission check failed: ${err.message}`); + } + + // ── 2. Count open Issues / PRs by this author ─────────────────── + // GitHub search API: one request for issues, one for PRs. + // We exclude the newly created item from each count so the + // threshold is "already had N open BEFORE this one". + + async function countOpen(type) { + // type = "issue" or "pr" + const qualifier = type === "issue" ? "is:issue" : "is:pr"; + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:open ${qualifier} author:${actor}`, + per_page: 100, // max per page; enough for any sane contributor + }); + // Subtract 1 for the item we just opened (it's already in the index) + return Math.max(0, data.total_count - 1); + } + + const openIssues = isIssue ? await countOpen("issue") : null; + const openPRs = isIssue ? null : await countOpen("pr"); + + core.info(`Open issues by ${actor} (excl. current): ${openIssues ?? "n/a"}`); + core.info(`Open PRs by ${actor} (excl. current): ${openPRs ?? "n/a"}`); + + // ── 3. Decide whether to close ────────────────────────────────── + const shouldClose = isIssue + ? openIssues >= MAX_OPEN_ISSUES + : openPRs >= MAX_OPEN_PRS; + + if (!shouldClose) { + core.info("Within limit — no action needed."); + return; + } + + core.info(`Limit exceeded — closing #${itemNumber} and adding label '${LABEL}'.`); + + // ── 4. Ensure the label exists (create it if needed) ───────────── + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch { + await github.rest.issues.createLabel({ + owner, repo, + name: LABEL, + color: "e11d48", // red-600 + description: "Closed automatically: contributor rate limit reached", + }); + } + + // ── 5. Add label ───────────────────────────────────────────────── + await github.rest.issues.addLabels({ + owner, repo, + issue_number: itemNumber, + labels: [LABEL], + }); + + // ── 6. Post comment ─────────────────────────────────────────────── + await github.rest.issues.createComment({ + owner, repo, + issue_number: itemNumber, + body: CLOSE_MESSAGE, + }); + + // ── 7. Close the item ───────────────────────────────────────────── + if (isIssue) { + await github.rest.issues.update({ + owner, repo, + issue_number: itemNumber, + state: "closed", + state_reason: "not_planned", + }); + } else { + await github.rest.pulls.update({ + owner, repo, + pull_number: itemNumber, + state: "closed", + }); + } + + core.info(`#${itemNumber} closed successfully.`); diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..08737d5a --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,82 @@ +name: "Stale Issue & PR " + +on: + schedule: + - cron: "0 0 * * *" + + workflow_dispatch: + +jobs: + stale: + name: Mark and Close Stale Issues & PRs + runs-on: ubuntu-latest + + permissions: + issues: write + pull-requests: write + + steps: + - name: Run Stale Action + + uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Inactivity thresholds (14 days warning + 7 days close = 21 days total) + days-before-stale: 14 + + days-before-close: 7 + + # Labels applied when stale + stale-issue-label: "stale" + + stale-pr-label: "stale" + + # Remove stale label automatically if activity resumes + remove-stale-when-updated: true + + # Warning comment posted on stale issues + + stale-issue-message: > + This issue has had no activity for **14 days** and has been + marked as `stale`. If this is still relevant, please leave a + comment or push an update within the next **7 days**, otherwise + it will be closed automatically to keep the tracker clean. + + # Warning comment posted on stale PRs + + stale-pr-message: > + This pull request has had no activity for **14 days** and + has been marked as `stale`. If this is still being worked on, + please push a commit or leave a comment within the next **7 days**, + otherwise it will be closed automatically. + + # Comment posted when a stale issue is closed + + close-issue-message: > + This issue has been automatically closed after 21 days of + inactivity. If still relevant, please open a new issue with + updated context. Thank you for contributing to PrepPilot ! + + # Comment posted when a stale PR is closed + + close-pr-message: > + This pull request has been automatically closed after 21 days + of inactivity. Please reopen or raise a fresh PR rebased on the + latest main branch if you would like to continue. Thank you! + + # Exemptions (Never touch these) + + exempt-issue-labels: "pinned,security,critical,in-progress,blocked,help wanted,gssoc:approved" + exempt-pr-labels: "pinned,security,critical,in-progress,blocked,do-not-merge,gssoc:approved" + + + # Cap API calls per run to respect GitHub rate limits + operations-per-run: 100 + + # Process oldest items first + ascending: true + + + + diff --git a/.gitignore b/.gitignore index 9ca6c2b8..bbe6906b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ coverage/ # Misc # ======================== *.local +merge-prs.js +check-labels.js diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 43da0b3b..50cda09a 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -197,7 +197,14 @@ Errors: - `DELETE /api/auth/delete-account` - Private -Permanently deletes the authenticated user's account. This action is irreversible. +Permanently deletes the authenticated user's account and all associated data. This action is irreversible and performs cascade deletion of: + +- Interview sessions and their associated questions +- Flashcards (SRS deck) +- Resumes +- Notes summaries +- Roadmap projects +- DSA sheet progress records Headers: ``` @@ -207,7 +214,7 @@ Response `200`: ```json { "success": true, - "message": "Account deleted successfully" + "message": "Account and all associated data deleted successfully" } ``` Errors: diff --git a/backend/Input_validators/ValidateRoadmap.js b/backend/Input_validators/ValidateRoadmap.js new file mode 100644 index 00000000..1c1b891a --- /dev/null +++ b/backend/Input_validators/ValidateRoadmap.js @@ -0,0 +1,136 @@ +const { z } = require("zod"); +const { handleValidationError } = require("./ValidateQuestions"); + +// ── Base field schemas (NO defaults here) ────────────────── +// Shared by both the create and update schemas below. Defaults are only +// applied when building createRoadmapSchema. If defaults were baked in here, +// `.partial()` on the update schema would NOT stop them from firing: Zod's +// `.default()` fills in a value any time a key is missing/undefined, +// regardless of whether the field is optional/partial. That previously +// caused every field the client didn't send on a PUT (e.g. a single-section +// regenerate request, which only sends the one changed key) to be reset to +// its empty default ([]/{}), and the controller then wrote those empty +// defaults over the rest of the saved roadmap — wiping it out. +const answersFields = z + .object({ + targetAudience: z.string().max(1000), + problemSolved: z.string().max(1000), + appType: z.string().max(200), + mvpFeatures: z.string().max(2000), + techPreferences: z.string().max(1000), + designStyle: z.string().max(1000), + accessibilityBranding: z.string().max(1000), + timeline: z.string().max(200), + }) + .partial(); + +const checklistItemFields = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(300), + completed: z.boolean().optional(), + notes: z.string().max(2000).optional(), +}); + +const milestoneFields = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(300), + description: z.string().max(2000).optional(), + order: z.number().optional(), + completed: z.boolean().optional(), + status: z.enum(["todo", "in-progress", "done"]).optional(), + notes: z.string().max(2000).optional(), + subtasks: z.array(checklistItemFields).optional(), +}); + +const techStackFields = z + .object({ + frontend: z.array(z.string()), + backend: z.array(z.string()), + database: z.array(z.string()), + other: z.array(z.string()), + }) + .partial(); + +const featurePrioritizationFields = z + .object({ + mvp: z.array(z.string()), + future: z.array(z.string()), + }) + .partial(); + +// ── Create schema: every field optional-with-default ────── +const createRoadmapSchema = z.object({ + projectIdea: z.string().min(1, "Project idea is required").max(500), + answers: answersFields.optional().default({}), + overview: z.string().max(5000).optional().default(""), + techStack: techStackFields.optional().default({}), + uiUxRecommendations: z.array(z.string()).optional().default([]), + milestones: z.array(milestoneFields).optional().default([]), + featurePrioritization: featurePrioritizationFields.optional().default({}), + databaseApiSuggestions: z.array(z.string()).optional().default([]), + deploymentRecommendations: z.array(z.string()).optional().default([]), + testingChecklist: z.array(checklistItemFields).optional().default([]), + lastStep: z.number().optional().default(0), +}); + +// ── Update schema: any subset of fields, no defaults ──────── +// Built from the same base field schemas so validation stays in sync with +// create, but nothing here has `.default()` — so a field the client doesn't +// send is simply absent from the parsed result, and the controller (which +// only assigns `req.body[field]` when it's `!== undefined`) leaves the +// existing saved value untouched instead of overwriting it with an empty one. +const updateRoadmapSchema = z.object({ + projectIdea: z.string().min(1, "Project idea is required").max(500).optional(), + answers: answersFields.optional(), + overview: z.string().max(5000).optional(), + techStack: techStackFields.optional(), + uiUxRecommendations: z.array(z.string()).optional(), + milestones: z.array(milestoneFields).optional(), + featurePrioritization: featurePrioritizationFields.optional(), + databaseApiSuggestions: z.array(z.string()).optional(), + deploymentRecommendations: z.array(z.string()).optional(), + testingChecklist: z.array(checklistItemFields).optional(), + lastStep: z.number().optional(), +}); + +const toggleTaskSchema = z.object({ + type: z.enum(["milestone", "subtask", "testing"]), + milestoneId: z.string().optional(), + taskId: z.string().optional(), + completed: z.boolean().optional(), + status: z.enum(["todo", "in-progress", "done"]).optional(), + notes: z.string().max(2000).optional(), +}); + +const validateCreateRoadmap = (req, res, next) => { + try { + req.body = createRoadmapSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +const validateUpdateRoadmap = (req, res, next) => { + try { + req.body = updateRoadmapSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +const validateToggleTask = (req, res, next) => { + try { + req.body = toggleTaskSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +module.exports = { + validateCreateRoadmap, + validateUpdateRoadmap, + validateToggleTask, +}; \ No newline at end of file diff --git a/backend/config/validateEnv.js b/backend/config/validateEnv.js index 7e3a77a4..03adf692 100644 --- a/backend/config/validateEnv.js +++ b/backend/config/validateEnv.js @@ -4,13 +4,18 @@ const requiredEnvVars = Object.freeze([ "GEMINI_API_KEY", ]); -// Optional integrations — the server boots fine without these, but the -// dependent feature is disabled until they are provided. -// ADZUNA_APP_ID / ADZUNA_API_KEY → "Jobs for You" (see controllers/jobController.js) +// Optional integrations mapping missing keys to dependent features +const optionalEnvGroups = Object.freeze([ + { + feature: "Jobs for You", + keys: ["ADZUNA_APP_ID", "ADZUNA_API_KEY"], + }, +]); const validateEnv = () => { const missingVars = []; + // 1. Check required environment variables requiredEnvVars.forEach((envVar) => { const value = process.env[envVar]; @@ -27,13 +32,27 @@ const validateEnv = () => { }); console.error( - "\n⚠️ Please add the missing environment variables to your .env file.\n", + "\n Please add the missing environment variables to your .env file.\n", ); process.exit(1); } - console.log("✅ Environment variables validated successfully\n"); + // 2. Check optional environment variables and warn if missing + optionalEnvGroups.forEach(({ feature, keys }) => { + const missingKeys = keys.filter((key) => { + const val = process.env[key]; + return !val || val.trim() === ""; + }); + + if (missingKeys.length > 0) { + console.warn( + `⚠️ [Optional Config] Missing: ${missingKeys.join(", ")} -> "${feature}" feature will be disabled.` + ); + } + }); + + console.log(" Environment variables validated successfully\n"); }; -module.exports = validateEnv; +module.exports = validateEnv; \ No newline at end of file diff --git a/backend/controllers/aiController.js b/backend/controllers/aiController.js index 09daf446..3b44b858 100644 --- a/backend/controllers/aiController.js +++ b/backend/controllers/aiController.js @@ -107,6 +107,16 @@ const generateInterviewQuestions = async (req, res) => { } } catch (error) { console.error("Gemini API Error:", error); + + if (error.status === 429) { + return res.status(429).json({ message: "Gemini API quota exceeded. Please try again later." }); + } + if (error.status === 401 || (error.message && error.message.includes("API key not valid"))) { + return res.status(401).json({ message: "Invalid Gemini API Key configured." }); + } + if (error.message && (error.message.includes("timeout") || error.message.includes("network"))) { + return res.status(504).json({ message: "Network timeout communicating with AI service." }); + } res.status(500).json({ message: "Failed to generate questions", }); @@ -172,6 +182,16 @@ const generateConceptExplanation = async (req, res) => { } } catch (error) { console.error("Gemini API Error:", error); + + if (error.status === 429) { + return res.status(429).json({ message: "Gemini API quota exceeded. Please try again later." }); + } + if (error.status === 401 || (error.message && error.message.includes("API key not valid"))) { + return res.status(401).json({ message: "Invalid Gemini API Key configured." }); + } + if (error.message && (error.message.includes("timeout") || error.message.includes("network"))) { + return res.status(504).json({ message: "Network timeout communicating with AI service." }); + } res.status(500).json({ message: "Failed to generate explanation", }); @@ -243,6 +263,15 @@ const generateInterviewTips = async (req, res) => { } } catch (error) { console.error("Gemini API Error:", error); + if (error.status === 429) { + return res.status(429).json({ message: "Gemini API quota exceeded. Please try again later." }); + } + if (error.status === 401 || (error.message && error.message.includes("API key not valid"))) { + return res.status(401).json({ message: "Invalid Gemini API Key configured." }); + } + if (error.message && (error.message.includes("timeout") || error.message.includes("network"))) { + return res.status(504).json({ message: "Network timeout communicating with AI service." }); + } res.status(500).json({ message: "Failed to generate interview tips", }); diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 89a51495..46817a8f 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -5,10 +5,21 @@ const crypto = require("crypto"); const { sendVerificationEmail } = require("../utils/sendEmail"); const { validatePassword } = require('../utils/passwordPolicy'); +// Models for cascade deletion on account delete +const Session = require("../models/Session"); +const Question = require("../models/Question"); +const Flashcard = require("../models/Flashcard"); +const Resume = require("../models/Resume"); +const NotesSummary = require("../models/NotesSummary"); +const RoadmapProject = require("../models/RoadmapProject"); +const UserSheetProgress = require("../models/UserSheetProgress"); + const ACCESS_TOKEN_EXPIRY = "15m"; const REFRESH_TOKEN_EXPIRY = "30d"; const REFRESH_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; const REFRESH_TOKEN_SALT_ROUNDS = 10; +const PASSWORD_SALT_ROUNDS = 10; + const getRefreshCookieOptions = () => ({ httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -44,14 +55,39 @@ const generateRefreshToken = (userId) => { const registerUser = async (req, res) => { try { const { name, email, password, profileImageUrl } = req.body; + + // Early payload presence & type validation to prevent unhandled TypeErrors (#758) + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ + success: false, + message: "Name is required and must be a non-empty string.", + }); + } + + if (!email || typeof email !== "string" || !email.trim()) { + return res.status(400).json({ + success: false, + message: "Email is required and must be a valid string.", + }); + } + + if (!password || typeof password !== "string") { + return res.status(400).json({ + success: false, + message: "Password is required.", + }); + } + + const cleanName = name.trim(); + const cleanEmail = email.trim().toLowerCase(); const emailRegex = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/; - if (!emailRegex.test(email)) { - return res.status(400).json({ - success: false, - message: "Please enter a valid email address.", - }); + if (!emailRegex.test(cleanEmail)) { + return res.status(400).json({ + success: false, + message: "Please enter a valid email address.", + }); } const { valid, errors } = validatePassword(password); @@ -59,24 +95,33 @@ const registerUser = async (req, res) => { return res.status(400).json({ success: false, message: errors[0] }); } - const userExists = await User.findOne({ email }); + const userExists = await User.findOne({ email: cleanEmail }); if (userExists) { - return res.status(400).json({ success: false, message: "A user with this email already exists." }); + // Do not reveal whether the email is already registered. Respond + // with the same generic success shape (no tokens, no user data) + // so the endpoint cannot be used for account enumeration. + return res.status(201).json({ + success: true, + message: "If this email is not already registered, your account has been created.", + }); } + // Hash raw password with bcrypt before DB creation (#757) + const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); + // Split name into first and last names for defaults - const nameParts = name.trim().split(/\s+/); + const nameParts = cleanName.split(/\s+/); const firstName = nameParts[0] || ""; const lastName = nameParts.slice(1).join(" ") || ""; // Generate default unique PrepPilot ID - const defaultPrepPilotId = email.split("@")[0] + Math.floor(1000 + Math.random() * 9000); + const defaultPrepPilotId = cleanEmail.split("@")[0] + Math.floor(1000 + Math.random() * 9000); // Auto-verify user — email verification temporarily disabled const user = await User.create({ - name, - email, - password: password, + name: cleanName, + email: cleanEmail, + password: hashedPassword, profileImageUrl, firstName, lastName, @@ -104,7 +149,6 @@ const registerUser = async (req, res) => { success: true, message: "Account created successfully. You can now log in.", accessToken, - _id: user._id, name: user.name, email: user.email, @@ -124,7 +168,11 @@ const loginUser = async (req, res) => { try { const { email, password } = req.body; - const user = await User.findOne({ email }); + if (!email || !password) { + return res.status(400).json({ success: false, message: "Email and password are required." }); + } + + const user = await User.findOne({ email: email.trim().toLowerCase() }); if (!user) { return res.status(401).json({ success: false, message: "Invalid email or password provided." }); } @@ -157,7 +205,6 @@ const loginUser = async (req, res) => { email: user.email, profileImageUrl: user.profileImageUrl, accessToken, - }); } catch (error) { console.error("Login error:", error); @@ -216,7 +263,6 @@ const refreshToken = async (req, res) => { success: true, message: "Token refreshed successfully.", accessToken, - }); } catch (error) { console.error("Refresh token error:", error); @@ -318,7 +364,7 @@ const resendVerificationEmail = async (req, res) => { return res.status(400).json({ success: false, message: "Email is required." }); } - const user = await User.findOne({ email }); + const user = await User.findOne({ email: email.trim().toLowerCase() }); // Return success even if user not found — avoids exposing which emails are registered if (!user) { @@ -352,11 +398,11 @@ const resendVerificationEmail = async (req, res) => { const getUserProfile = async (req, res) => { try { const user = req.user; - if(!user){ + if (!user) { return res.status(404).json({ success: false, message: "Requested user profile not found" }); } res.json(user); - }catch(error){ + } catch (error) { console.error("Get profile error:", error); res.status(500).json({ success: false, message: "Internal server error occurred" }); } @@ -449,7 +495,7 @@ const updateUserProfile = async (req, res) => { await user.save(); - // return updated user, excluding password + // Return updated user, excluding password const updatedUser = await User.findById(userId).select("-password"); res.json(updatedUser); } catch (error) { @@ -487,12 +533,17 @@ const changePassword = async (req, res) => { return res.status(400).json({ success: false, message: "Incorrect original password" }); } - // Hash new password - user.password = newPassword; - // Invalidate access tokens issued before the password change. + // Hash new password before saving (#757) + user.password = await bcrypt.hash(newPassword, PASSWORD_SALT_ROUNDS); + + // Fix #759: Revoke active refresh tokens in database & increment tokenVersion for access tokens + user.refreshTokenHash = null; + user.refreshTokenExpiresAt = null; user.tokenVersion = (user.tokenVersion || 0) + 1; await user.save(); + // Fix #759: Clear refresh cookie on client response + res.clearCookie("refreshToken", { path: "/api/auth" }); res.json({ success: true, message: "Password updated successfully" }); } catch (error) { console.error("Change password error:", error); @@ -501,7 +552,8 @@ const changePassword = async (req, res) => { }; /** - * Permanently delete user account. + * Permanently delete user account and all associated data. + * Implements cascade deletion to clean up orphaned documents. * @route DELETE /api/auth/delete-account */ const deleteUserAccount = async (req, res) => { @@ -512,12 +564,74 @@ const deleteUserAccount = async (req, res) => { return res.status(404).json({ success: false, message: "User not found" }); } + // Cascade delete: remove all user-related data + const deletePromises = []; + + // Delete user's sessions and their associated questions + const sessions = await Session.find({ user: userId }); + const sessionIds = sessions.map(s => s._id); + if (sessionIds.length > 0) { + deletePromises.push( + Question.deleteMany({ session: { $in: sessionIds } }) + ); + } + deletePromises.push(Session.deleteMany({ user: userId })); + + // Delete user's flashcards + deletePromises.push( + Flashcard.deleteMany({ userId: userId }) + ); + + // Delete user's resumes + deletePromises.push( + Resume.deleteMany({ user: userId }) + ); + + // Delete user's notes summaries + deletePromises.push( + NotesSummary.deleteMany({ user: userId }) + ); + + // Delete user's roadmap projects + deletePromises.push( + RoadmapProject.deleteMany({ userId: userId }) + ); + + // Delete user's sheet progress + deletePromises.push( + UserSheetProgress.deleteMany({ userId: userId }) + ); + + // Wait for all deletions to complete + await Promise.all(deletePromises); + + // Finally, delete the user account await User.findByIdAndDelete(userId); - res.json({ success: true, message: "Account deleted successfully" }); + + // Clear auth cookies + res.clearCookie("refreshToken", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + path: "/api/auth" + }); + + res.json({ success: true, message: "Account and all associated data deleted successfully" }); } catch (error) { console.error("Delete account error:", error); res.status(500).json({ success: false, message: "Internal server error occurred" }); } }; -module.exports = { registerUser, loginUser, refreshToken, logoutUser, verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount }; \ No newline at end of file +module.exports = { + registerUser, + loginUser, + refreshToken, + logoutUser, + verifyEmail, + resendVerificationEmail, + getUserProfile, + updateUserProfile, + changePassword, + deleteUserAccount, +}; \ No newline at end of file diff --git a/backend/controllers/flashcardController.js b/backend/controllers/flashcardController.js index 01975ee0..90cdd3fc 100644 --- a/backend/controllers/flashcardController.js +++ b/backend/controllers/flashcardController.js @@ -42,7 +42,7 @@ const calculateSM2 = ({ interval = 0, repetition = 0, efactor = 2.5 }, rating) = const q = score; newEFactor = newEFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)); if (newEFactor < 1.3) newEFactor = 1.3; - newEFactor = Math.round(newEFactor * 100) / 100; + newEFactor = Math.round(newEFactor * 100 + Number.EPSILON) / 100; const now = new Date(); const nextDueDate = new Date(now.getTime() + newInterval * 24 * 60 * 60 * 1000); diff --git a/backend/controllers/jobController.js b/backend/controllers/jobController.js index b001cb01..7c759468 100644 --- a/backend/controllers/jobController.js +++ b/backend/controllers/jobController.js @@ -91,7 +91,7 @@ exports.refreshJobCache = async () => { { jobs, fetchedAt: new Date() }, { upsert: true, new: true } ); - console.log(`[JobCron] Refreshed cache for: ${role}`); + } } catch (err) { console.error("[JobCron] Refresh failed:", err.message); diff --git a/backend/controllers/notesSummaryController.js b/backend/controllers/notesSummaryController.js index e0258d6b..4e96a9ce 100644 --- a/backend/controllers/notesSummaryController.js +++ b/backend/controllers/notesSummaryController.js @@ -363,7 +363,7 @@ const getMySummaries = async (req, res) => { try { const summaries = await NotesSummary.find({ user: req.user._id }).sort({ updatedAt: -1, - }); + }).limit(50); res.status(200).json({ success: true, summaries: summaries.map((s) => s.toSafeObject()), diff --git a/backend/controllers/questionController.js b/backend/controllers/questionController.js index 98180d4a..720cbdb2 100644 --- a/backend/controllers/questionController.js +++ b/backend/controllers/questionController.js @@ -1,6 +1,100 @@ +const mongoose = require("mongoose"); const Question = require("../models/Question"); const Session = require("../models/Session"); +/** + * Get all questions for the authenticated user across their sessions. + * @route GET /api/questions/my-questions + * @query pinned=true|false (optional) + * @query sessionId= (optional) + * @query q= (optional, text search on question/answer) + * @query page= (default 1) + * @query limit= (default 20, max 100) + * @returns { success, questions, pagination } + */ +const getMyQuestions = async (req, res) => { + try { + const userId = req.user._id; + + // First get the user's session IDs + const sessions = await Session.find({ user: userId }).select("_id").lean(); + const sessionIds = sessions.map((s) => s._id); + + if (sessionIds.length === 0) { + return res.json({ + success: true, + questions: [], + pagination: { totalItems: 0, totalPages: 0, page: 1, pageSize: 20, hasNextPage: false }, + }); + } + + const { + pinned, + sessionId, + q, + page = 1, + limit = 20, + } = req.query; + + const filter = { session: { $in: sessionIds } }; + + if (pinned === "true" || pinned === "false") { + filter.isPinned = pinned === "true"; + } + + if (sessionId) { + if (!mongoose.isValidObjectId(sessionId)) { + return res.status(400).json({ success: false, message: "Invalid sessionId" }); + } + // Ensure the session belongs to the user + if (!sessionIds.some((id) => id.toString() === sessionId)) { + return res.status(403).json({ success: false, message: "Session does not belong to user" }); + } + filter.session = sessionId; + } + + if (typeof q === "string" && q.trim().length > 0) { + const searchRegex = new RegExp(q.trim(), "i"); + filter.$or = [ + { question: searchRegex }, + { answer: searchRegex }, + { note: searchRegex }, + ]; + } + + const pageNum = Math.max(1, parseInt(page, 10)); + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10))); + const skip = (pageNum - 1) * limitNum; + + const [questions, totalItems] = await Promise.all([ + Question.find(filter) + .sort({ isPinned: -1, createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .populate("session", "role topicsToFocus description") + .lean(), + Question.countDocuments(filter), + ]); + + const totalPages = Math.ceil(totalItems / limitNum); + + res.json({ + success: true, + questions, + pagination: { + totalItems, + totalPages, + page: pageNum, + pageSize: limitNum, + hasNextPage: pageNum < totalPages, + }, + }); + } catch (err) { + console.error("Get my questions error:", err); + res.status(500).json({ success: false, message: "Internal server error occurred" }); + } +}; + /** * Add additional questions to an existing session. * @route POST /api/questions/add @@ -159,4 +253,5 @@ module.exports = { addQuestionToSession, togglePinQuestion, updateQuestionNote, + getMyQuestions, }; diff --git a/backend/controllers/resumeController.js b/backend/controllers/resumeController.js index df3c06f6..cff5f9bc 100644 --- a/backend/controllers/resumeController.js +++ b/backend/controllers/resumeController.js @@ -228,7 +228,7 @@ const saveResume = async (req, res) => { const getMyResumes = async (req, res) => { try { const userId = req.user._id; - const resumes = await Resume.find({ user: userId }).sort({ updatedAt: -1 }); + const resumes = await Resume.find({ user: userId }).sort({ updatedAt: -1 }).limit(50); res.status(200).json({ success: true, resumes }); } catch (error) { console.error("Get Resumes Error:", error); diff --git a/backend/controllers/roadmapController.js b/backend/controllers/roadmapController.js new file mode 100644 index 00000000..6196503c --- /dev/null +++ b/backend/controllers/roadmapController.js @@ -0,0 +1,285 @@ +const RoadmapProject = require("../models/RoadmapProject"); + +const MAX_SAVED_ROADMAPS_PER_USER = 30; + +/** + * @desc Save a newly AI-generated project roadmap + * @route POST /api/roadmaps + * @access Private + */ +const createRoadmap = async (req, res) => { + try { + const userId = req.user._id; + + const existingCount = await RoadmapProject.countDocuments({ userId }); + if (existingCount >= MAX_SAVED_ROADMAPS_PER_USER) { + return res.status(400).json({ + success: false, + message: `You can save up to ${MAX_SAVED_ROADMAPS_PER_USER} roadmaps. Delete an existing one to add a new project.`, + }); + } + + const roadmap = new RoadmapProject({ + ...req.body, + userId, + }); + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(201).json({ + success: true, + message: "Roadmap saved to your account", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to save roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Get all saved roadmaps for the authenticated user (dashboard summary) + * @route GET /api/roadmaps + * @access Private + */ +const getUserRoadmaps = async (req, res) => { + try { + const userId = req.user._id; + const roadmaps = await RoadmapProject.find({ userId }) + .sort({ updatedAt: -1 }) + .limit(50) + .select( + "projectIdea overview progressPercent status milestones testingChecklist createdAt updatedAt lastStep" + ); + + const summaries = roadmaps.map((r) => ({ + _id: r._id, + projectIdea: r.projectIdea, + overview: r.overview, + progressPercent: r.progressPercent, + status: r.status, + milestoneCount: r.milestones.length, + completedMilestoneCount: r.milestones.filter((m) => m.completed).length, + lastStep: r.lastStep, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + + return res.status(200).json({ + success: true, + count: summaries.length, + roadmaps: summaries, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch roadmaps", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Get a single roadmap in full detail + * @route GET /api/roadmaps/:id + * @access Private + */ +const getRoadmapById = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + return res.status(200).json({ success: true, roadmap }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Update a roadmap - edit milestones, swap in a regenerated AI section, + * rename the project, or update the questionnaire answers. + * @route PUT /api/roadmaps/:id + * @access Private + */ +const updateRoadmap = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + const editableFields = [ + "projectIdea", + "answers", + "overview", + "techStack", + "uiUxRecommendations", + "milestones", + "featurePrioritization", + "databaseApiSuggestions", + "deploymentRecommendations", + "testingChecklist", + "lastStep", + ]; + + editableFields.forEach((field) => { + if (req.body[field] !== undefined) { + roadmap[field] = req.body[field]; + } + }); + + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(200).json({ + success: true, + message: "Roadmap updated", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to update roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Toggle completion (and/or update notes) of a milestone, subtask, or + * testing-checklist item, then recompute overall progress. + * @route PATCH /api/roadmaps/:id/tasks + * @access Private + */ +const toggleTask = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + const { type, milestoneId, taskId, completed, status, notes } = req.body; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + if (type === "milestone") { + const milestone = roadmap.milestones.find((m) => m.id === milestoneId); + if (!milestone) { + return res.status(404).json({ success: false, message: "Milestone not found" }); + } + if (completed !== undefined) milestone.completed = completed; + if (notes !== undefined) milestone.notes = notes; + if (status !== undefined) { + milestone.status = status; + milestone.completed = status === "done"; + } + // If a milestone with subtasks is toggled directly, cascade to subtasks + if ( + (completed !== undefined || status !== undefined) && + milestone.subtasks?.length > 0 + ) { + const nowCompleted = status !== undefined ? status === "done" : completed; + milestone.subtasks.forEach((s) => (s.completed = nowCompleted)); + } + } else if (type === "subtask") { + const milestone = roadmap.milestones.find((m) => m.id === milestoneId); + if (!milestone) { + return res.status(404).json({ success: false, message: "Milestone not found" }); + } + const subtask = milestone.subtasks.find((s) => s.id === taskId); + if (!subtask) { + return res.status(404).json({ success: false, message: "Subtask not found" }); + } + if (completed !== undefined) subtask.completed = completed; + if (notes !== undefined) subtask.notes = notes; + } else if (type === "testing") { + const item = roadmap.testingChecklist.find((t) => t.id === taskId); + if (!item) { + return res.status(404).json({ success: false, message: "Checklist item not found" }); + } + if (completed !== undefined) item.completed = completed; + if (notes !== undefined) item.notes = notes; + } + + roadmap.markModified("milestones"); + roadmap.markModified("testingChecklist"); + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(200).json({ + success: true, + message: "Progress updated", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to update task", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Delete a saved roadmap + * @route DELETE /api/roadmaps/:id + * @access Private + */ +const deleteRoadmap = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOneAndDelete({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + return res.status(200).json({ + success: true, + message: "Roadmap deleted successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to delete roadmap", + error: "A server error occurred", + }); + } +}; + +module.exports = { + createRoadmap, + getUserRoadmaps, + getRoadmapById, + updateRoadmap, + toggleTask, + deleteRoadmap, +}; \ No newline at end of file diff --git a/backend/controllers/sessionController.js b/backend/controllers/sessionController.js index 4da73ca6..8912d6cc 100644 --- a/backend/controllers/sessionController.js +++ b/backend/controllers/sessionController.js @@ -28,42 +28,50 @@ const MAX_EXPERIENCE = 50; */ exports.createSession = async (req, res) => { - const mongoSession = await mongoose.startSession(); - + let mongoSession; try { - await mongoSession.withTransaction(async () => { - const userId = req.user._id; - const { role, experience, topicsToFocus, description } = req.body; - const experienceNumber = Number(experience); - - if (!role || role.trim() === "") { - return res.status(400).json({ - success: false, - message: "Role is required.", - }); - } + const userId = req.user._id; + const { role, experience, topicsToFocus, description } = req.body; + const experienceNumber = Number(experience); + + // Validate before opening the transaction so 4xx responses are sent + // from the outer handler, never from inside the transaction callback + // (returning inside withTransaction resolves it and commits). + if (!role || role.trim() === "") { + return res.status(400).json({ + success: false, + message: "Role is required.", + }); + } - if (Number.isNaN(experienceNumber)) { - return res.status(400).json({ - success: false, - message: "Years of experience must be a valid number.", - }); - } - - if (experienceNumber < 0 || experienceNumber > MAX_EXPERIENCE) { - return res.status(400).json({ - success: false, - message: `Years of experience must be between 0 and ${MAX_EXPERIENCE}.`, - }); - } - const sessionCount = await Session.countDocuments({ - user: userId, - }).session(mongoSession); + if (Number.isNaN(experienceNumber)) { + return res.status(400).json({ + success: false, + message: "Years of experience must be a valid number.", + }); + } - if (sessionCount >= MAX_SESSIONS) { - throw new Error("SESSION_LIMIT_REACHED"); - } + if (experienceNumber < 0 || experienceNumber > MAX_EXPERIENCE) { + return res.status(400).json({ + success: false, + message: `Years of experience must be between 0 and ${MAX_EXPERIENCE}.`, + }); + } + + const sessionCount = await Session.countDocuments({ + user: userId, + }); + if (sessionCount >= MAX_SESSIONS) { + return res.status(400).json({ + success: false, + message: `Maximum of ${MAX_SESSIONS} sessions reached.`, + }); + } + + mongoSession = await mongoose.startSession(); + + const created = await mongoSession.withTransaction(async () => { const createdSession = await Session.create( [ { @@ -92,26 +100,24 @@ await createdSession[0].save({ session: mongoSession, }); - res.status(201).json({ - success: true, - session: createdSession[0], - }); + return createdSession[0]; }); - } catch (err) { - if (err.message === "SESSION_LIMIT_REACHED") { - return res.status(400).json({ - success: false, - message: `Maximum of ${MAX_SESSIONS} sessions reached.`, - }); - } + // Respond only after the transaction has committed. + res.status(201).json({ + success: true, + session: created, + }); + } catch (err) { console.error("Create session error:", err); return res.status(500).json({ success: false, message: "Internal server error occurred", }); } finally { - await mongoSession.endSession(); + if (mongoSession) { + await mongoSession.endSession(); + } } }; diff --git a/backend/controllers/userSheetProgressController.js b/backend/controllers/userSheetProgressController.js index 47cf4c5a..124e097f 100644 --- a/backend/controllers/userSheetProgressController.js +++ b/backend/controllers/userSheetProgressController.js @@ -1,4 +1,8 @@ const UserSheetProgress = require("../models/UserSheetProgress"); +const { + normalizeProgressItems, + buildBulkOps, +} = require("../utils/sheetProgressImport"); /** * Get all sheet progress entries for the authenticated user. @@ -8,7 +12,7 @@ exports.getAllProgress = async (req, res) => { const userId = req.user._id; try { - const progressList = await UserSheetProgress.find({ userId }); + const progressList = await UserSheetProgress.find({ userId }).limit(50); res.json({ success: true, @@ -143,4 +147,86 @@ exports.getProgress = async (req, res) => { error: "Internal server error occurred", }); } +}; + +/** + * Export all sheet progress for the authenticated user as a JSON file. + * @route GET /api/user/sheet-progress/export + */ +exports.exportProgress = async (req, res) => { + const userId = req.user._id; + + try { + const progressList = await UserSheetProgress.find({ userId }).lean(); + const backup = { + version: 1, + exportedAt: new Date().toISOString(), + count: progressList.length, + items: progressList.map((p) => ({ + sheetId: p.sheetId, + followed: p.followed, + completedTopics: p.completedTopics, + percentage: p.percentage, + updatedAt: p.updatedAt, + })), + }; + + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + 'attachment; filename="sheet-progress-backup.json"' + ); + res.send(JSON.stringify(backup, null, 2)); + } catch (err) { + res.status(500).json({ + success: false, + error: "Internal server error occurred", + }); + } +}; + +/** + * Import sheet progress entries for the authenticated user. + * Valid entries are bulk-upserted; invalid ones are skipped and reported. + * @route POST /api/user/sheet-progress/import + */ +exports.importProgress = async (req, res) => { + const userId = req.user._id; + const { items } = req.body || {}; + + const { items: normalized, errors } = normalizeProgressItems(items); + if (errors.length > 0 && normalized.length === 0) { + return res.status(400).json({ + success: false, + error: errors[0], + }); + } + + if (normalized.length === 0) { + return res.status(400).json({ + success: false, + error: "No valid entries to import", + }); + } + + try { + const result = await UserSheetProgress.bulkWrite( + buildBulkOps(userId, normalized), + { ordered: false } + ); + + res.json({ + success: true, + imported: normalized.length, + skipped: errors.length, + updated: result.matchedCount || 0, + created: result.upsertedCount || 0, + warnings: errors.slice(0, 20), + }); + } catch (err) { + res.status(500).json({ + success: false, + error: "Internal server error occurred", + }); + } }; \ No newline at end of file diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index b0223357..400ea324 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -8,7 +8,7 @@ const protect = async (req, res, next) => { try { let token = req.headers.authorization; - if (token && token.startsWith("Bearer")) { + if (token && token.toLowerCase().startsWith("bearer")) { // Extract token from "Bearer " token = token.split(" ")[1]; diff --git a/backend/middlewares/sanitizeAiPrompt.js b/backend/middlewares/sanitizeAiPrompt.js index 45767b6a..75507303 100644 --- a/backend/middlewares/sanitizeAiPrompt.js +++ b/backend/middlewares/sanitizeAiPrompt.js @@ -7,6 +7,10 @@ const sanitizeField = (value) => { .trim(); }; +// Exported so history messages and other assembled AI content can be +// sanitized with the same rules as the prompt field. +const sanitizePromptText = sanitizeField; + const sanitizeAiPrompt = (req, res, next) => { if (req.body) { req.body.prompt = sanitizeField(req.body.prompt); @@ -17,4 +21,5 @@ const sanitizeAiPrompt = (req, res, next) => { next(); }; -module.exports = sanitizeAiPrompt; \ No newline at end of file +module.exports = sanitizeAiPrompt; +module.exports.sanitizePromptText = sanitizePromptText; \ No newline at end of file diff --git a/backend/middlewares/uploadMiddleware.js b/backend/middlewares/uploadMiddleware.js index 1a95f5bd..c64b7855 100644 --- a/backend/middlewares/uploadMiddleware.js +++ b/backend/middlewares/uploadMiddleware.js @@ -5,34 +5,18 @@ const { fileTypeFromBuffer } = require("file-type"); const MAX_FILE_SIZE = 5 * 1024 * 1024; -const sanitizeFilename = (filename) => { - const ext = path.extname(filename); - const basename = path.basename(filename, ext); - - const sanitizedBase = basename +const sanitizeBase = (filename) => + path + .basename(filename, path.extname(filename)) .replace(/[^a-zA-Z0-9_-]/g, "_") .replace(/_+/g, "_") - .replace(/^_+|_+$/g, ""); - - return `${sanitizedBase || "file"}${ext.toLowerCase()}`; -}; + .replace(/^_+|_+$/g, "") || "file"; // Create uploads directory if it doesn't exist if (!fs.existsSync("uploads")) { fs.mkdirSync("uploads"); } -// Configure storage -const diskStorage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, "uploads/"); - }, - filename: (req, file, cb) => { - const safeFilename = sanitizeFilename(file.originalname); - cb(null, `${Date.now()}-${safeFilename}`); - }, -}); - // File filter for image uploads const imageFileFilter = (req, file, cb) => { const allowedTypes = [ @@ -49,6 +33,77 @@ const imageFileFilter = (req, file, cb) => { } }; +// MIME type -> server-decided whitelisted extension. Extensions are never +// taken from the client-supplied filename. +const IMAGE_EXTENSION_BY_MIME = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; + +/** + * Detect the true image type from the content's magic bytes and derive a + * server-decided filename with a whitelisted extension. Returns null when the + * buffer is not a supported image, so the client mimetype/extension can never + * be trusted to determine what gets stored or served. + * @param {import('multer').File} file - Multer file with a Buffer (memory storage). + * @returns {Promise} Stored filename, or null if content is not an image. + */ +const resolveImageFileName = async (file) => { + const fileType = await fileTypeFromBuffer(file.buffer); + const ext = fileType && IMAGE_EXTENSION_BY_MIME[fileType.mime]; + if (!ext) return null; + return `${Date.now()}-${sanitizeBase(file.originalname)}.${ext}`; +}; + +/** + * Post-upload middleware for profile images: validates the content by magic + * bytes (never the client mimetype), writes the file to disk using a + * server-decided whitelisted extension, and stashes the stored filename on + * req.file.filename for the route handler. + */ +const validateImageUpload = async (req, res, next) => { + if (!req.file) { + return res.status(400).json({ success: false, message: "No file uploaded" }); + } + + try { + const filename = await resolveImageFileName(req.file); + if (!filename) { + return res.status(400).json({ + success: false, + message: "Invalid image file. Only JPEG, PNG, and WebP images are allowed.", + }); + } + await fs.promises.writeFile(path.join("uploads", filename), req.file.buffer); + req.file.filename = filename; + next(); + } catch (error) { + console.error("Error validating image upload:", error); + return res.status(500).json({ success: false, message: "Failed to validate image" }); + } +}; + +// Extensions that are safe to render inline from /uploads. +const SERVED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +/** + * setHeaders callback for the /uploads express.static mount. Prevents content + * sniffing, sandboxes any inline rendering, and forces non-image content to be + * downloaded as an attachment instead of executed by the browser. + * @param {import('express').Response} res + * @param {string} filePath + */ +const uploadsStaticHeaders = (res, filePath) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + const ext = path.extname(filePath).toLowerCase(); + if (!SERVED_IMAGE_EXTENSIONS.has(ext)) { + res.setHeader("Content-Disposition", "attachment"); + res.setHeader("Content-Type", "application/octet-stream"); + } +}; + // File filter for resume (PDF) uploads with basic MIME type check const resumeFileFilter = (req, file, cb) => { if (file.mimetype === "application/pdf") { @@ -90,9 +145,10 @@ const validateResumeMagicBytes = async (req, res, next) => { } }; -// Upload instance for images (disk storage) +// Upload instance for images (memory storage so magic bytes can be inspected +// before anything is persisted; the client mimetype/filename is never trusted) const upload = multer({ - storage: diskStorage, + storage: multer.memoryStorage(), fileFilter: imageFileFilter, limits: { fileSize: MAX_FILE_SIZE }, }); @@ -113,4 +169,13 @@ const uploadNotes = multer({ limits: { fileSize: NOTES_MAX_FILE_SIZE }, }); -module.exports = { upload, uploadResume, uploadNotes, NOTES_MAX_FILE_SIZE, validateResumeMagicBytes }; +module.exports = { + upload, + uploadResume, + uploadNotes, + NOTES_MAX_FILE_SIZE, + validateResumeMagicBytes, + validateImageUpload, + resolveImageFileName, + uploadsStaticHeaders, +}; diff --git a/backend/models/Question.js b/backend/models/Question.js index 1d519457..bca3db66 100644 --- a/backend/models/Question.js +++ b/backend/models/Question.js @@ -1,6 +1,6 @@ const mongoose = require("mongoose"); const questionSchema = new mongoose.Schema({ - session:{type:mongoose.Schema.Types.ObjectId,ref:"Session"}, + session:{type:mongoose.Schema.Types.ObjectId,ref:"Session", index: true}, question:String, answer:String, note:String, diff --git a/backend/models/Resume.js b/backend/models/Resume.js index 7f0ed6a4..09f68825 100644 --- a/backend/models/Resume.js +++ b/backend/models/Resume.js @@ -2,7 +2,7 @@ const mongoose = require("mongoose"); const ResumeSchema = new mongoose.Schema( { - user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, title: { type: String, required: true }, latexCode: { type: String, required: true }, }, diff --git a/backend/models/RoadmapProject.js b/backend/models/RoadmapProject.js new file mode 100644 index 00000000..d145c2ea --- /dev/null +++ b/backend/models/RoadmapProject.js @@ -0,0 +1,143 @@ +const mongoose = require("mongoose"); + +// A single checklist-style item (used for subtasks and testing checklist items) +const ChecklistItemSchema = new mongoose.Schema( + { + id: { type: String, required: true }, + title: { type: String, required: true, trim: true }, + completed: { type: Boolean, default: false }, + notes: { type: String, default: "", trim: true }, + }, + { _id: false } +); + +const MilestoneSchema = new mongoose.Schema( + { + id: { type: String, required: true }, + title: { type: String, required: true, trim: true }, + description: { type: String, default: "", trim: true }, + order: { type: Number, default: 0 }, + completed: { type: Boolean, default: false }, + // Drives the Kanban board column. Auto-derived from subtasks when present, + // otherwise toggled manually by the user. + status: { + type: String, + enum: ["todo", "in-progress", "done"], + default: "todo", + }, + notes: { type: String, default: "", trim: true }, + subtasks: { type: [ChecklistItemSchema], default: [] }, + }, + { _id: false } +); + +const roadmapProjectSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + projectIdea: { + type: String, + required: [true, "Project idea is required"], + trim: true, + }, + + // Answers collected from the follow-up questionnaire + answers: { + targetAudience: { type: String, default: "", trim: true }, + problemSolved: { type: String, default: "", trim: true }, + appType: { type: String, default: "", trim: true }, + mvpFeatures: { type: String, default: "", trim: true }, + techPreferences: { type: String, default: "", trim: true }, + designStyle: { type: String, default: "", trim: true }, + accessibilityBranding: { type: String, default: "", trim: true }, + timeline: { type: String, default: "", trim: true }, + }, + + // AI-generated roadmap content + overview: { type: String, default: "", trim: true }, + techStack: { + frontend: { type: [String], default: [] }, + backend: { type: [String], default: [] }, + database: { type: [String], default: [] }, + other: { type: [String], default: [] }, + }, + uiUxRecommendations: { type: [String], default: [] }, + milestones: { type: [MilestoneSchema], default: [] }, + featurePrioritization: { + mvp: { type: [String], default: [] }, + future: { type: [String], default: [] }, + }, + databaseApiSuggestions: { type: [String], default: [] }, + deploymentRecommendations: { type: [String], default: [] }, + testingChecklist: { type: [ChecklistItemSchema], default: [] }, + + // Progress + workflow metadata + progressPercent: { type: Number, default: 0, min: 0, max: 100 }, + status: { + type: String, + enum: ["planning", "in-progress", "completed"], + default: "planning", + }, + lastStep: { type: Number, default: 0 }, // resume-from-where-you-left-off pointer for the questionnaire + }, + { timestamps: true } +); + +/** + * Recompute progressPercent from milestone subtask + testing checklist completion. + * Called before saving whenever tasks are toggled. + */ +roadmapProjectSchema.methods.recomputeProgress = function () { + let total = 0; + let done = 0; + + this.milestones.forEach((m) => { + if (m.subtasks && m.subtasks.length > 0) { + m.subtasks.forEach((s) => { + total += 1; + if (s.completed) done += 1; + }); + } else { + total += 1; + if (m.completed) done += 1; + } + }); + + this.testingChecklist.forEach((t) => { + total += 1; + if (t.completed) done += 1; + }); + + this.progressPercent = total === 0 ? 0 : Math.round((done / total) * 100); + + // Keep milestone.completed / status in sync with its subtasks + this.milestones.forEach((m) => { + if (m.subtasks && m.subtasks.length > 0) { + const doneCount = m.subtasks.filter((s) => s.completed).length; + m.completed = doneCount === m.subtasks.length; + if (m.completed) m.status = "done"; + else if (doneCount > 0) m.status = "in-progress"; + else if (m.status === "done") m.status = "todo"; + } else { + m.completed = m.status === "done"; + } + }); + + if (this.progressPercent === 100 && total > 0) { + this.status = "completed"; + } else if (this.progressPercent > 0) { + this.status = "in-progress"; + } else { + this.status = "planning"; + } + + return this.progressPercent; +}; + +roadmapProjectSchema.index({ userId: 1, updatedAt: -1 }); + +module.exports = mongoose.model("RoadmapProject", roadmapProjectSchema); \ No newline at end of file diff --git a/backend/models/Session.js b/backend/models/Session.js index 0a6a92cd..46ddb13c 100644 --- a/backend/models/Session.js +++ b/backend/models/Session.js @@ -1,7 +1,7 @@ const mongoose = require("mongoose"); const Question = require("./Question"); const sessionSchema = new mongoose.Schema({ - user: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", index: true }, role: { type: String, required: true }, experience: { type: String, required: true }, topicsToFocus: { type: [String], required: true }, diff --git a/backend/models/User.js b/backend/models/User.js index 2952c791..5d0d6b3c 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -77,6 +77,21 @@ UserSchema.methods.isValidPassword = async function(candidatePassword) { // Compare candidate password with the stored hash return await bcrypt.compare(candidatePassword, this.password); }; - + +// Never serialize secrets when a User document is sent over the wire (e.g. +// res.json(user) in GET /api/auth/profile). These fields remain readable in +// code where they are explicitly needed (login comparison, refresh rotation), +// but are stripped from every JSON output. +UserSchema.set("toJSON", { + transform: (doc, ret) => { + delete ret.password; + delete ret.refreshTokenHash; + delete ret.refreshTokenExpiresAt; + delete ret.emailVerificationToken; + delete ret.emailVerificationExpires; + delete ret.tokenVersion; + return ret; + }, +}); module.exports = mongoose.model("User", UserSchema); diff --git a/backend/package-lock.json b/backend/package-lock.json index 3dd2d7aa..2d269c86 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,7 @@ "mongoose": "^8.18.0", "multer": "^2.0.2", "node-cache": "^5.1.2", - "nodemailer": "^8.0.10", + "nodemailer": "^9.0.4", "pdf-parse": "^2.4.5", "resend": "^6.12.4", "zod": "^4.4.3" @@ -331,6 +331,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "node_modules/@babel/plugin-syntax-import-meta": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", @@ -344,6 +361,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], "node_modules/@babel/plugin-syntax-json-strings": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", @@ -934,6 +968,23 @@ } } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "node_modules/@jest/schemas": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", @@ -4075,6 +4126,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4136,6 +4201,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -4204,6 +4281,22 @@ "node": ">=4" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "license": "BSD-2-Clause", "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4481,6 +4574,23 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/file-type": { + "version": "18.7.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", + "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4594,6 +4704,31 @@ "node": ">=8" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5974,67 +6109,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mongoose/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", @@ -6271,9 +6345,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", - "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz", + "integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/backend/package.json b/backend/package.json index e922eff7..47b201d7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,7 @@ "mongoose": "^8.18.0", "multer": "^2.0.2", "node-cache": "^5.1.2", - "nodemailer": "^8.0.10", + "nodemailer": "^9.0.4", "pdf-parse": "^2.4.5", "resend": "^6.12.4", "zod": "^4.4.3" diff --git a/backend/routes/AptitudeQuestions.js b/backend/routes/AptitudeQuestions.js index 4db48872..8fbd53d1 100644 --- a/backend/routes/AptitudeQuestions.js +++ b/backend/routes/AptitudeQuestions.js @@ -14,7 +14,7 @@ const INITIAL_DELAY = 1000; // GET /api/questions?topic=Probability router.get("/", async (req, res) => { const { topic } = req.query; - if (typeof topic !== "string" || topic.trim() === "") { + if (typeof topic !== "string" || topic.trim().length === 0) { return res.status(400).json({ error: "Topic is required" }); } const normalizedTopic = topic.trim().toLowerCase(); diff --git a/backend/routes/aiRoutes.js b/backend/routes/aiRoutes.js index 64524425..937108dc 100644 --- a/backend/routes/aiRoutes.js +++ b/backend/routes/aiRoutes.js @@ -5,12 +5,150 @@ const { generateChatWithFallback } = require('../utils/geminiHelper'); const { aiLimiter } = require('../middlewares/rateLimiter'); const { validateAiPrompt } = require('../middlewares/validateAiPrompt'); const sanitizeAiPrompt = require('../middlewares/sanitizeAiPrompt'); +const { sanitizePromptText } = sanitizeAiPrompt; const { isPrepPilotDomain, isContextualResponse } = require('../utils/domainClassifier'); +const { buildSolverPrompt, parseSolverOutput } = require('../utils/problemSolverParser'); +const problemSolverSchema = require('../validation/problemSolverSchema'); +const { z } = require("zod"); const NodeCache = require('node-cache'); // Cache to track off-topic attempts per IP (TTL: 1 hour) const offTopicCache = new NodeCache({ stdTTL: 3600 }); +// Server-owned system instruction. Never taken from the request body, so a +// caller cannot override the model's persona/guardrails. +const SYSTEM_INSTRUCTION = `You are PrepPilot AI Mentor. +1. Allow friendly greetings and casual onboarding conversation. +2. Focus primarily on PrepPilot-related domains: interview preparation, coding interviews, aptitude, resumes, career guidance, mock interviews, and platform usage. +3. Politely redirect unrelated conversations. +4. End your responses with a helpful, contextual follow-up question whenever appropriate (e.g., asking if they want an example, feedback on a resume section, or practice questions).`; + +const MAX_HISTORY_MESSAGES = 20; +// Combined character budget for prompt + history (≈ rough token guard) to stop +// unbounded per-request token spend through Gemini. +const MAX_COMBINED_CHARS = 16000; + +/** + * Sanitize and cap the chat payload (prompt + history) before it reaches the + * model. The system instruction is intentionally NOT part of this contract. + * @param {string} prompt + * @param {unknown} history + * @returns {{ ok: true, formattedHistory: Array } | { ok: false, error: string }} + */ +const buildChatPayload = (prompt, history) => { + if (!Array.isArray(history)) { + return { ok: false, error: "history must be an array" }; + } + if (history.length > MAX_HISTORY_MESSAGES) { + return { ok: false, error: "Conversation history is too large" }; + } + + let totalChars = typeof prompt === "string" ? sanitizePromptText(prompt).length : 0; + const formattedHistory = []; + + for (const msg of history) { + const text = typeof msg?.text === "string" ? sanitizePromptText(msg.text) : ""; + totalChars += text.length; + formattedHistory.push({ + role: msg?.role === "model" ? "model" : "user", + parts: [{ text }], + }); + } + + if (totalChars > MAX_COMBINED_CHARS) { + return { ok: false, error: "Prompt and history are too large" }; + } + + return { ok: true, formattedHistory }; +}; + +/** + * Validate a problem-solving payload against the problemSolverSchema. + */ +function validateProblemSolve(req, res, next) { + try { + const parsed = problemSolverSchema.parse(req.body); + const clean = (value) => + typeof value === "string" + ? value.replace(/<[^>]*>?/gm, "").replace(/[^\x20-\x7E\n]/g, "").trim() + : value; + req.solveInput = { + problem: clean(parsed.problem), + language: parsed.language, + constraints: clean(parsed.constraints), + }; + next(); + } catch (err) { + if (err instanceof z.ZodError) { + const first = err.issues[0]; + return res.status(400).json({ + error: first?.message || "Invalid solve request.", + details: err.issues.map((i) => i.message), + }); + } + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * Structured problem-solving endpoint. + * @route POST /api/solve + * @param {import('express').Request} req + * @param {import('express').Response} res + * @example + * POST /api/solve + * { + * "problem": "Two Sum...", + * "language": "python", + * "constraints": "1 <= nums.length <= 10^4" + * } + * @example + * 200 {"success":true,"solution":{"approach":"...","steps":"...","complexity":"...","code":"...","language":"python"}} + */ +async function solveHandler(req, res) { + const { problem, language, constraints } = req.solveInput; + + try { + const prompt = buildSolverPrompt({ problem, language, constraints }); + const { result, usedModel } = await generateChatWithFallback( + process.env.GEMINI_API_KEY, + prompt, + [], + { + systemInstruction: + "You are an expert coding interview tutor. Always answer with the exact markdown structure requested. Never wrap the whole answer in a code fence.", + } + ); + + const rawText = await result.response.text(); + const parsed = parseSolverOutput(rawText); + + if (!parsed.ok) { + return res.json({ + success: false, + solution: null, + raw: parsed.raw, + model: usedModel, + }); + } + + return res.json({ + success: true, + solution: { + approach: parsed.sections.approach, + steps: parsed.sections.steps || "", + complexity: parsed.sections.complexity || "", + code: parsed.sections.code || "", + language, + }, + model: usedModel, + }); + } catch (error) { + console.error("[AI] Solve failed:", error); + return res.status(500).json({ error: "Failed to generate solution" }); + } +} + /** * Shared handler for text generation using Gemini. * @param {import('express').Request} req @@ -26,7 +164,7 @@ const offTopicCache = new NodeCache({ stdTTL: 3600 }); * 200 {"text": "...", "model": "models/gemini-2.5-flash"} */ async function generateHandler(req, res) { - const { prompt, history = [], systemInstruction } = req.body || {}; + const { prompt, history = [] } = req.body || {}; if (!prompt || !prompt.trim()) { return res.status(400).json({ error: "Missing prompt" }); } @@ -59,17 +197,14 @@ async function generateHandler(req, res) { } try { const start = Date.now(); - const systemInstructionText = systemInstruction || `You are PrepPilot AI Mentor. -1. Allow friendly greetings and casual onboarding conversation. -2. Focus primarily on PrepPilot-related domains: interview preparation, coding interviews, aptitude, resumes, career guidance, mock interviews, and platform usage. -3. Politely redirect unrelated conversations. -4. End your responses with a helpful, contextual follow-up question whenever appropriate (e.g., asking if they want an example, feedback on a resume section, or practice questions).`; - // Format history for Gemini API - let formattedHistory = history.map(msg => ({ - role: msg.role === "model" ? "model" : "user", - parts: [{ text: msg.text }] - })); + // Build the model payload server-side: sanitized history with hard caps. + // The system instruction is always the server-owned constant. + const built = buildChatPayload(prompt, history); + if (!built.ok) { + return res.status(400).json({ error: built.error }); + } + const formattedHistory = built.formattedHistory; // Gemini requires the first message in history to be from the user if (formattedHistory.length > 0 && formattedHistory[0].role !== "user") { @@ -80,7 +215,7 @@ async function generateHandler(req, res) { process.env.GEMINI_API_KEY, prompt, formattedHistory, - { systemInstruction: systemInstructionText } + { systemInstruction: SYSTEM_INSTRUCTION } ); const rawText = await result.response.text(); @@ -91,12 +226,7 @@ async function generateHandler(req, res) { .replace(/```$/i, "") .trim(); - console.log( - "[AI] promptLen=%d model=%s ms=%d", - prompt.length, - usedModel, - Date.now() - start, - ); + return res.json({ text: cleanedText, model: usedModel }); } catch (error) { console.error("[AI] Generation failed:", error); @@ -111,6 +241,9 @@ router.post('/generate', aiLimiter, validateAiPrompt, sanitizeAiPrompt, generate // Alias under /ai for consistency if needed later (/api/ai/generate) router.post('/ai/generate', aiLimiter, validateAiPrompt, sanitizeAiPrompt, generateHandler); +// Structured problem-solving route +router.post('/solve', aiLimiter, sanitizeAiPrompt, validateProblemSolve, solveHandler); + // List available models /** * List available Gemini models configured for the backend. @@ -141,3 +274,7 @@ router.get("/models", async (req, res) => { }); module.exports = router; +module.exports.buildChatPayload = buildChatPayload; +module.exports.SYSTEM_INSTRUCTION = SYSTEM_INSTRUCTION; +module.exports.MAX_HISTORY_MESSAGES = MAX_HISTORY_MESSAGES; +module.exports.MAX_COMBINED_CHARS = MAX_COMBINED_CHARS; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 21a837a3..152e5552 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,7 +1,7 @@ const express = require("express"); const { registerUser, loginUser, verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount, refreshToken, logoutUser } = require("../controllers/authController"); const { protect } = require("../middlewares/authMiddleware"); -const { upload } = require("../middlewares/uploadMiddleware"); +const { upload, validateImageUpload } = require("../middlewares/uploadMiddleware"); const { validateUserLogin, validateUserSignup, validateRefreshToken, validateResendEmail } = require("../Input_validators/ValidateAuth"); const csrfHeaderCheck = require("../middlewares/csrfHeaderCheck"); const router = express.Router(); @@ -20,7 +20,7 @@ const { // Auth Routes router.post("/register", authLimiter, validateUserSignup, registerUser); -router.post("/login", authLimiter, validateUserLogin, loginUser); +router.post("/login", loginLimiter, validateUserLogin, loginUser); // Frontend should GET this once on app load to prime the XSRF-TOKEN cookie // before it ever needs to call /refresh or /logout. @@ -35,16 +35,13 @@ router.put("/profile", protect, generalLimiter, updateUserProfile); router.put("/change-password", protect, sensitiveAuthLimiter, changePassword); router.delete("/delete-account", protect, sensitiveAuthLimiter, deleteUserAccount); router.post("/resend-verification", authLimiter, validateResendEmail, resendVerificationEmail); -router.get("/verify-email", verifyEmail); +router.get("/verify-email", authLimiter, verifyEmail); /** * Upload a user profile image. * @route POST /api/auth/upload-image */ -router.post("/upload-image", protect, generalLimiter, upload.single("image"), (req, res) => { - if (!req.file) { - return res.status(400).json({ message: "No file uploaded" }); - } +router.post("/upload-image", protect, generalLimiter, upload.single("image"), validateImageUpload, (req, res) => { const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get("host")}`; const imageUrl = `${baseUrl}/uploads/${req.file.filename}`; res.status(200).json({ imageUrl }); diff --git a/backend/routes/booksRoutes.js b/backend/routes/booksRoutes.js index 6d9804df..f7aafef5 100644 --- a/backend/routes/booksRoutes.js +++ b/backend/routes/booksRoutes.js @@ -265,4 +265,4 @@ router.get("/download", (req, res) => { }); module.exports = router; -module.exports.listFilesRecursive = listFilesRecursive; +module.exports.listFilesRecursive = listFilesRecursive; \ No newline at end of file diff --git a/backend/routes/questionRoutes.js b/backend/routes/questionRoutes.js index dad0cc2b..1ee94f8a 100644 --- a/backend/routes/questionRoutes.js +++ b/backend/routes/questionRoutes.js @@ -1,10 +1,14 @@ const express = require('express') -const {togglePinQuestion, updateQuestionNote,addQuestionToSession} = require("../controllers/questionController"); +const { + togglePinQuestion, + updateQuestionNote, + addQuestionToSession, + getMyQuestions, +} = require("../controllers/questionController"); const {protect} = require("../middlewares/authMiddleware"); const { generalLimiter } = require("../middlewares/rateLimiter"); const { validateAddQuestionToSession, validateTogglePinQuestion, validateUpdateQuestionNote } = require('../Input_validators/ValidateQuestions'); - const router = express.Router(); /** @@ -12,6 +16,12 @@ const router = express.Router(); */ router.use(generalLimiter, protect); +/** + * Get all questions for the authenticated user across their sessions. + * @route GET /api/questions/my-questions + */ +router.get('/my-questions', getMyQuestions); + /** * Add new questions to an existing session. * @route POST /api/question/add diff --git a/backend/routes/roadmapRoutes.js b/backend/routes/roadmapRoutes.js new file mode 100644 index 00000000..4ddd9614 --- /dev/null +++ b/backend/routes/roadmapRoutes.js @@ -0,0 +1,35 @@ +const express = require("express"); +const router = express.Router(); + +const { protect } = require("../middlewares/authMiddleware"); +const { generalLimiter } = require("../middlewares/rateLimiter"); + +const { + validateCreateRoadmap, + validateUpdateRoadmap, + validateToggleTask, +} = require("../Input_validators/ValidateRoadmap"); + +const { + createRoadmap, + getUserRoadmaps, + getRoadmapById, + updateRoadmap, + toggleTask, + deleteRoadmap, +} = require("../controllers/roadmapController"); + +// Apply rate limiting first +router.use(generalLimiter); + +// Then authentication +router.use(protect); + +router.post("/", validateCreateRoadmap, createRoadmap); +router.get("/", getUserRoadmaps); +router.get("/:id", getRoadmapById); +router.put("/:id", validateUpdateRoadmap, updateRoadmap); +router.patch("/:id/tasks", validateToggleTask, toggleTask); +router.delete("/:id", deleteRoadmap); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/sheetJsonUpload.js b/backend/routes/sheetJsonUpload.js index 1b80e6c7..c007e764 100644 --- a/backend/routes/sheetJsonUpload.js +++ b/backend/routes/sheetJsonUpload.js @@ -2,6 +2,10 @@ const express = require('express'); const router = express.Router(); const Sheet = require('../models/Sheet'); const { protect } = require('../middlewares/authMiddleware'); +const { + normalizeSheet, + computeSheetStats, +} = require('../utils/sheetValidation'); // POST /api/sheets/upload // Body: { filename: "file.json", data: {...sheet data...} } @@ -21,15 +25,20 @@ router.post('/upload', protect, async (req, res) => { const results = []; for (const sheetObj of sheetsArr) { - if (!sheetObj || !sheetObj.id || !sheetObj.title) { - results.push({ error: 'Invalid sheet data. Each sheet needs id & title.', sheet: sheetObj }); + const normalized = normalizeSheet(sheetObj); + if (!normalized.ok) { + results.push({ + error: 'Invalid sheet data.', + details: normalized.errors, + sheet: sheetObj, + }); continue; } // Insert or update sheet by id const sheet = await Sheet.findOneAndUpdate( - { id: sheetObj.id }, - sheetObj, + { id: normalized.value.id }, + normalized.value, { upsert: true, new: true, setDefaultsOnInsert: true } ); @@ -43,13 +52,121 @@ router.post('/upload', protect, async (req, res) => { } }); +// POST /api/sheets/validate +// Body: { data: {...sheet data...} } +// Dry-run: returns stats + errors without writing to the DB. +router.post('/validate', protect, async (req, res) => { + const { data } = req.body; + + if (!data) { + return res.status(400).json({ error: 'Sheet data is required.' }); + } + + const sheetsArr = Array.isArray(data.sheets) ? data.sheets : [data]; + const results = []; + + for (const sheetObj of sheetsArr) { + const normalized = normalizeSheet(sheetObj); + if (!normalized.ok) { + results.push({ + id: sheetObj && sheetObj.id ? sheetObj.id : null, + ok: false, + errors: normalized.errors, + }); + continue; + } + results.push({ + id: normalized.value.id, + title: normalized.value.title, + ok: true, + stats: computeSheetStats(normalized.value), + }); + } + + const invalidCount = results.filter((r) => !r.ok).length; + res.json({ + valid: results.length - invalidCount, + invalid: invalidCount, + total: results.length, + results, + }); +}); + +// PUT /api/sheets/:id +// Update a single sheet after validation. +router.put('/:id', protect, async (req, res) => { + const { id } = req.params; + const body = req.body && req.body.sheet ? req.body.sheet : req.body; + + const normalized = normalizeSheet(body); + if (!normalized.ok) { + return res.status(400).json({ error: 'Invalid sheet data.', details: normalized.errors }); + } + + if (normalized.value.id && normalized.value.id !== id) { + return res.status(400).json({ error: 'Sheet id in body does not match URL id.' }); + } + + try { + const sheet = await Sheet.findOneAndUpdate( + { id }, + normalized.value, + { new: true, setDefaultsOnInsert: true } + ); + + if (!sheet) { + return res.status(404).json({ error: 'Sheet not found.' }); + } + + res.json({ message: 'Sheet updated.', sheet }); + } catch (err) { + console.error('Error updating sheet:', err); + res.status(500).json({ error: 'Failed to update sheet.' }); + } +}); + +// DELETE /api/sheets/:id +// Requires confirmId matching :id to prevent accidental deletion. +router.delete('/:id', protect, async (req, res) => { + const { id } = req.params; + const { confirmId } = req.body || {}; + + if (confirmId !== id) { + return res.status(400).json({ error: 'confirmId must match the sheet id.' }); + } + + try { + const sheet = await Sheet.findOneAndDelete({ id }); + + if (!sheet) { + return res.status(404).json({ error: 'Sheet not found.' }); + } + + res.json({ message: 'Sheet deleted.', id }); + } catch (err) { + console.error('Error deleting sheet:', err); + res.status(500).json({ error: 'Failed to delete sheet.' }); + } +}); + // GET / - fetch all sheets (for /api/sheets) router.get('/', async (req, res) => { try { - const sheets = await Sheet.find({}); - res.json({ sheets }); + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 50; + const skip = (page - 1) * limit; + + const sheets = await Sheet.find({}).skip(skip).limit(limit); + const total = await Sheet.countDocuments({}); + + res.json({ + sheets, + currentPage: page, + totalPages: Math.ceil(total / limit), + totalSheets: total + }); } catch (err) { console.error('Error fetching sheets:', err); res.status(500).json({ error: 'Failed to fetch sheets.' }); @@ -60,7 +177,7 @@ router.get('/', async (req, res) => { // GET /:id - fetch single sheet by id (for /api/sheets/:id) router.get('/:id', async (req, res) => { try { - // Always find by custom id field (string) + // Always find by cust const sheet = await Sheet.findOne({ id: req.params.id }); if (!sheet) { return res.status(404).json({ error: 'Sheet not found.' }); diff --git a/backend/routes/userSheetProgressRoutes.js b/backend/routes/userSheetProgressRoutes.js index c202302e..1a645b18 100644 --- a/backend/routes/userSheetProgressRoutes.js +++ b/backend/routes/userSheetProgressRoutes.js @@ -1,18 +1,35 @@ const express = require('express'); -const { saveProgress, getProgress } = require('../controllers/userSheetProgressController'); +const { + saveProgress, + getProgress, + getAllProgress, + exportProgress, + importProgress, +} = require('../controllers/userSheetProgressController'); const { protect } = require('../middlewares/authMiddleware'); const { validateSaveProgress, validateGetProgress } = require('../Input_validators/ValidateUserSheetProgress'); const router = express.Router(); +router.use(protect); /** * Save or update progress for a user sheet. * @route POST /api/user/sheet-progress */ - -router.use(protect); router.post('/sheet-progress', validateSaveProgress, saveProgress); +/** + * Export all sheet progress for the authenticated user as a JSON file. + * @route GET /api/user/sheet-progress/export + */ +router.get('/sheet-progress/export', exportProgress); + +/** + * Import sheet progress entries for the authenticated user. + * @route POST /api/user/sheet-progress/import + */ +router.post('/sheet-progress/import', importProgress); + /** * Get progress for a specific user sheet. * @route GET /api/user/sheet-progress/:sheetId @@ -23,6 +40,6 @@ router.get('/sheet-progress/:sheetId', validateGetProgress, getProgress); * Get all sheet progress records for the authenticated user. * @route GET /api/user/sheet-progress */ -router.get('/sheet-progress', require('../controllers/userSheetProgressController').getAllProgress); +router.get('/sheet-progress', getAllProgress); module.exports = router; diff --git a/backend/scripts/uploadSheets.js b/backend/scripts/uploadSheets.js index c88917f2..acd131ca 100644 --- a/backend/scripts/uploadSheets.js +++ b/backend/scripts/uploadSheets.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const SHEETS_DIR = path.join(__dirname, '../sheets'); -const API_URL = 'http://localhost:8000/api/sheets/upload'; // Use local backend for uploads +const API_URL = process.env.API_URL || 'http://localhost:8000/api/sheets/upload'; // Use local backend for uploads by default async function uploadSheet(filename) { const filePath = path.join(SHEETS_DIR, filename); diff --git a/backend/server.js b/backend/server.js index 84aa724d..1a6341b8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,11 +2,15 @@ require("dotenv").config(); const validateEnv = require("./config/validateEnv.js"); validateEnv(); const express = require("express"); -const cors = require("cors"); + +// Global unhandled promise rejection handler +process.on("unhandledRejection", (err) => { + console.error("Unhandled Promise Rejection:", err); +}); + const path = require("path"); const connectDB = require("./config/db"); const cookieParser = require("cookie-parser"); -const helmet = require("helmet"); const { generateInterviewQuestions, generateConceptExplanation, @@ -22,11 +26,11 @@ const aptitudeQuestionsRoutes = require("./routes/AptitudeQuestions.js"); const jobRoutes = require("./routes/jobRoutes"); const { generalLimiter, aiLimiter } = require("./middlewares/rateLimiter"); const { generalHeaders, sensitiveRouteHeaders } = require("./middlewares/securityHeaders"); +const { uploadsStaticHeaders } = require("./middlewares/uploadMiddleware"); const app = express(); app.set("trust proxy", 1); -app.use(helmet()); -app.use(generalHeaders); +app.use(generalHeaders); const isDev = process.env.NODE_ENV !== "production"; const originEnvList = [ process.env.FRONTEND_ORIGIN, @@ -40,15 +44,15 @@ const allowedOrigins = new Set(originEnvList); app.use((req, res, next) => { const origin = req.headers.origin; - const renderPattern = - /^https:\/\/(?:interview-prep(?:aration)?-ai|preppilot(?:-backend)?)-[a-z0-9-]+\.onrender\.com$/; + // Exact-origin allowlist only (FRONTEND_ORIGIN / EXTRA_ORIGINS, plus + // localhost in dev). No regex wildcard matching of third-party-registrable + // domains like *.onrender.com — anyone can register an attacker subdomain + // that would otherwise be granted credentialed CORS access. const localhostPattern = /^http:\/\/(localhost|127\.0\.0\.1):(5\d{3}|3\d{3})$/; if ( origin && - (allowedOrigins.has(origin) || - renderPattern.test(origin) || - localhostPattern.test(origin)) + (allowedOrigins.has(origin) || localhostPattern.test(origin)) ) { res.header("Access-Control-Allow-Origin", origin); res.header("Vary", "Origin"); @@ -65,7 +69,7 @@ app.use((req, res, next) => { } if (req.method === "OPTIONS") { res.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS,PATCH"); - res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token, x-requested-with"); + res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-requested-with"); return res.sendStatus(200); } next(); @@ -76,19 +80,16 @@ connectDB() if (success) { console.log("MongoDB connected successfully"); } else { - console.warn( - "⚠️ Failed to connect to MongoDB - server will run without database connection", - ); + console.warn("⚠️ Failed to connect to MongoDB - server will run without database connection"); } }) .catch((err) => { console.error("Database connection error:", err.message); }); -// middleware +// Middleware app.use(express.json()); app.use(cookieParser()); -app.use(generalLimiter); // Apply rate limiter globally //Routes app.use("/api/auth", sensitiveRouteHeaders,authRoutes); @@ -143,9 +144,16 @@ const coursesRoutes = require("./routes/coursesRoutes"); app.use("/api/courses", generalLimiter, coursesRoutes); const flashcardRoutes = require("./routes/flashcardRoutes"); app.use("/api/flashcards", generalLimiter, flashcardRoutes); +const roadmapRoutes = require("./routes/roadmapRoutes"); +app.use("/api/roadmaps", roadmapRoutes); -app.use("/uploads", express.static(path.join(__dirname, "uploads"), {})); +app.use( + "/uploads", + express.static(path.join(__dirname, "uploads"), { + setHeaders: uploadsStaticHeaders, + }) +); // Debug route to verify backend is working app.get("/api/test", (req, res) => { diff --git a/backend/tests/aiChatPayload.unit.test.js b/backend/tests/aiChatPayload.unit.test.js new file mode 100644 index 00000000..7ea9a071 --- /dev/null +++ b/backend/tests/aiChatPayload.unit.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// --------------------------------------------------------------------------- +// AI chat payload hardening (issue #924): +// - systemInstruction must not be client-controlled (server owns it) +// - prompt + history must be sanitized and capped +// --------------------------------------------------------------------------- + +let buildChatPayload; +let SYSTEM_INSTRUCTION; +let MAX_HISTORY_MESSAGES; +let MAX_COMBINED_CHARS; + +beforeAll(async () => { + const mod = await import("../routes/aiRoutes.js"); + buildChatPayload = mod.buildChatPayload; + SYSTEM_INSTRUCTION = mod.SYSTEM_INSTRUCTION; + MAX_HISTORY_MESSAGES = mod.MAX_HISTORY_MESSAGES; + MAX_COMBINED_CHARS = mod.MAX_COMBINED_CHARS; +}); + +describe("buildChatPayload — caps and sanitization", () => { + it("formats a valid history", () => { + const built = buildChatPayload("Explain closures", [ + { role: "user", text: "Hi" }, + { role: "model", text: "Hello!" }, + ]); + expect(built.ok).toBe(true); + expect(built.formattedHistory).toEqual([ + { role: "user", parts: [{ text: "Hi" }] }, + { role: "model", parts: [{ text: "Hello!" }] }, + ]); + }); + + it("rejects a non-array history", () => { + const built = buildChatPayload("Hello", { nope: true }); + expect(built.ok).toBe(false); + }); + + it("rejects more than 20 history messages", () => { + const history = Array.from({ length: MAX_HISTORY_MESSAGES + 1 }, () => ({ + role: "user", + text: "x", + })); + const built = buildChatPayload("Hello", history); + expect(built.ok).toBe(false); + }); + + it("rejects a prompt + history payload over the combined character budget", () => { + const big = "a".repeat(MAX_COMBINED_CHARS + 1); + const built = buildChatPayload(big, []); + expect(built.ok).toBe(false); + }); + + it("strips markup from history text", () => { + const built = buildChatPayload("Hello", [{ role: "user", text: "bold" }]); + expect(built.ok).toBe(true); + expect(built.formattedHistory[0].parts[0].text).toBe("bold"); + }); + + it("coerces non-string history text to empty instead of failing", () => { + const built = buildChatPayload("Hello", [{ role: "user", text: 123 }]); + expect(built.ok).toBe(true); + expect(built.formattedHistory[0].parts[0].text).toBe(""); + }); +}); + +describe("SYSTEM_INSTRUCTION is server-owned", () => { + it("exists and is non-empty", () => { + expect(SYSTEM_INSTRUCTION).toBeTruthy(); + expect(SYSTEM_INSTRUCTION).toContain("PrepPilot"); + }); +}); diff --git a/backend/tests/authController.deleteAccount.cascade.unit.test.js b/backend/tests/authController.deleteAccount.cascade.unit.test.js new file mode 100644 index 00000000..19acfca1 --- /dev/null +++ b/backend/tests/authController.deleteAccount.cascade.unit.test.js @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ─── Module Mocks ───────────────────────────────────────────────────────────── + +vi.mock("../models/User.js", () => ({ + findById: vi.fn(), + findByIdAndDelete: vi.fn(), +})); + +vi.mock("../models/Session.js", () => ({ + find: vi.fn(), + deleteMany: vi.fn(), +})); + +vi.mock("../models/Question.js", () => ({ + deleteMany: vi.fn(), +})); + +vi.mock("../models/Flashcard.js", () => ({ + deleteMany: vi.fn(), +})); + +vi.mock("../models/Resume.js", () => ({ + deleteMany: vi.fn(), +})); + +vi.mock("../models/NotesSummary.js", () => ({ + deleteMany: vi.fn(), +})); + +vi.mock("../models/RoadmapProject.js", () => ({ + deleteMany: vi.fn(), +})); + +vi.mock("../models/UserSheetProgress.js", () => ({ + deleteMany: vi.fn(), +})); + +// ─── Test Variables ─────────────────────────────────────────────────────────── + +let deleteUserAccount; +let User; +let Session; +let Question; +let Flashcard; +let Resume; +let NotesSummary; +let RoadmapProject; +let UserSheetProgress; + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + process.env.JWT_SECRET = "test-secret"; + process.env.NODE_ENV = "test"; + + const ctrl = await import("../controllers/authController.js"); + deleteUserAccount = ctrl.deleteUserAccount ?? ctrl.default?.deleteUserAccount; + + const UserModule = await import("../models/User.js"); + User = UserModule.default ?? UserModule; + + const SessionModule = await import("../models/Session.js"); + Session = SessionModule.default ?? SessionModule; + + const QuestionModule = await import("../models/Question.js"); + Question = QuestionModule.default ?? QuestionModule; + + const FlashcardModule = await import("../models/Flashcard.js"); + Flashcard = FlashcardModule.default ?? FlashcardModule; + + const ResumeModule = await import("../models/Resume.js"); + Resume = ResumeModule.default ?? ResumeModule; + + const NotesSummaryModule = await import("../models/NotesSummary.js"); + NotesSummary = NotesSummaryModule.default ?? NotesSummaryModule; + + const RoadmapProjectModule = await import("../models/RoadmapProject.js"); + RoadmapProject = RoadmapProjectModule.default ?? RoadmapProjectModule; + + const UserSheetProgressModule = await import("../models/UserSheetProgress.js"); + UserSheetProgress = UserSheetProgressModule.default ?? UserSheetProgressModule; +}); + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +/** Build a minimal Express-style res mock with chainable .status() */ +const makeRes = () => { + const res = { + status: vi.fn(), + json: vi.fn(), + clearCookie: vi.fn(), + }; + res.status.mockReturnValue(res); + return res; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// deleteUserAccount +// ───────────────────────────────────────────────────────────────────────────── + +describe("deleteUserAccount", () => { + it("returns 404 when user is not found", async () => { + User.findById.mockResolvedValueOnce(null); + + const req = { user: { _id: "non-existent-user-id" } }; + const res = makeRes(); + + await deleteUserAccount(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "User not found", + }) + ); + }); + + it("deletes user and all associated data when account is deleted", async () => { + const userId = "user-to-delete-id"; + const mockUser = { + _id: userId, + name: "Test User", + email: "test@example.com", + }; + + User.findById.mockResolvedValueOnce(mockUser); + User.findByIdAndDelete.mockResolvedValueOnce(mockUser); + + // Mock sessions with questions + const mockSessions = [ + { _id: "session-1" }, + { _id: "session-2" }, + ]; + Session.find.mockResolvedValueOnce(mockSessions); + Session.deleteMany.mockResolvedValueOnce({ deletedCount: 2 }); + Question.deleteMany.mockResolvedValueOnce({ deletedCount: 5 }); + + // Mock other collections + Flashcard.deleteMany.mockResolvedValueOnce({ deletedCount: 3 }); + Resume.deleteMany.mockResolvedValueOnce({ deletedCount: 2 }); + NotesSummary.deleteMany.mockResolvedValueOnce({ deletedCount: 1 }); + RoadmapProject.deleteMany.mockResolvedValueOnce({ deletedCount: 4 }); + UserSheetProgress.deleteMany.mockResolvedValueOnce({ deletedCount: 6 }); + + const req = { user: { _id: userId } }; + const res = makeRes(); + + await deleteUserAccount(req, res); + + // Verify cascade deletions were called + expect(Session.find).toHaveBeenCalledWith({ user: userId }); + expect(Question.deleteMany).toHaveBeenCalledWith({ + session: { $in: ["session-1", "session-2"] }, + }); + expect(Session.deleteMany).toHaveBeenCalledWith({ user: userId }); + expect(Flashcard.deleteMany).toHaveBeenCalledWith({ userId: userId }); + expect(Resume.deleteMany).toHaveBeenCalledWith({ user: userId }); + expect(NotesSummary.deleteMany).toHaveBeenCalledWith({ user: userId }); + expect(RoadmapProject.deleteMany).toHaveBeenCalledWith({ userId: userId }); + expect(UserSheetProgress.deleteMany).toHaveBeenCalledWith({ userId: userId }); + + // Verify user account deletion + expect(User.findByIdAndDelete).toHaveBeenCalledWith(userId); + + // Verify cookie clearing + expect(res.clearCookie).toHaveBeenCalledWith( + "refreshToken", + expect.objectContaining({ + httpOnly: true, + path: "/api/auth", + }) + ); + + // Verify success response + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + message: "Account and all associated data deleted successfully", + }) + ); + }); + + it("handles deletion when user has no associated data", async () => { + const userId = "user-with-no-data-id"; + const mockUser = { + _id: userId, + name: "New User", + email: "new@example.com", + }; + + User.findById.mockResolvedValueOnce(mockUser); + User.findByIdAndDelete.mockResolvedValueOnce(mockUser); + + // User has no sessions + Session.find.mockResolvedValueOnce([]); + // No other data exists + Flashcard.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + Resume.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + NotesSummary.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + RoadmapProject.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + UserSheetProgress.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + + const req = { user: { _id: userId } }; + const res = makeRes(); + + await deleteUserAccount(req, res); + + // Verify user account was still deleted + expect(User.findByIdAndDelete).toHaveBeenCalledWith(userId); + + // Verify success response + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + message: "Account and all associated data deleted successfully", + }) + ); + }); + + it("returns 500 when database error occurs during deletion", async () => { + const userId = "user-id"; + const mockUser = { + _id: userId, + name: "Test User", + email: "test@example.com", + }; + + User.findById.mockResolvedValueOnce(mockUser); + User.findByIdAndDelete.mockRejectedValueOnce(new Error("Database connection lost")); + + // Mock cascade deletions + Session.find.mockResolvedValueOnce([]); + Flashcard.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + Resume.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + NotesSummary.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + RoadmapProject.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + UserSheetProgress.deleteMany.mockResolvedValueOnce({ deletedCount: 0 }); + + const req = { user: { _id: userId } }; + const res = makeRes(); + + await deleteUserAccount(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "Internal server error occurred", + }) + ); + }); +}); diff --git a/backend/tests/authRoutes.rateLimit.test.js b/backend/tests/authRoutes.rateLimit.test.js index 0c30550a..f169b107 100644 --- a/backend/tests/authRoutes.rateLimit.test.js +++ b/backend/tests/authRoutes.rateLimit.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict"); const router = require("../routes/authRoutes"); const { protect } = require("../middlewares/authMiddleware"); const { + loginLimiter, authLimiter, generalLimiter, sensitiveAuthLimiter, @@ -34,12 +35,11 @@ test("POST /register keeps authLimiter only", () => { assert.equal(stack.includes(generalLimiter), false); }); -test("POST /login keeps authLimiter only", () => { +test("POST /login mounts loginLimiter (strict brute-force protection)", () => { const stack = getRouteStack("POST", "/login"); assert.ok(stack); - assert.equal(stack.includes(authLimiter), true); - assert.equal(stack.includes(generalLimiter), false); + assert.equal(stack.includes(loginLimiter), true); }); test("GET /profile includes protect and generalLimiter in order", () => { diff --git a/backend/tests/cors.preflight.unit.test.js b/backend/tests/cors.preflight.unit.test.js index ced66d24..c66c3d70 100644 --- a/backend/tests/cors.preflight.unit.test.js +++ b/backend/tests/cors.preflight.unit.test.js @@ -15,16 +15,12 @@ function buildApp(allowedOrigins = new Set(["https://allowed.example.com"])) { app.use((req, res, next) => { const origin = req.headers.origin; - const renderPattern = - /^https:\/\/(?:interview-prep(?:aration)?-ai|preppilot-backend)-[a-z0-9-]+\.onrender\.com$/; const localhostPattern = /^http:\/\/(localhost|127\.0\.0\.1):(5\d{3}|3\d{3})$/; if ( origin && - (allowedOrigins.has(origin) || - renderPattern.test(origin) || - localhostPattern.test(origin)) + (allowedOrigins.has(origin) || localhostPattern.test(origin)) ) { res.header("Access-Control-Allow-Origin", origin); res.header("Vary", "Origin"); @@ -95,19 +91,37 @@ describe("CORS preflight — approved origin", () => { }); // --------------------------------------------------------------------------- -// Approved origin — Render dynamic subdomain pattern +// Attacker-registrable onrender subdomain — must be rejected (issue #921) // --------------------------------------------------------------------------- -describe("CORS preflight — approved Render subdomain", () => { - it("returns 200 for a Render preview subdomain", async () => { +describe("CORS preflight — onrender.com subdomain (attacker-registrable)", () => { + it("returns 403 for a preppilot attacker subdomain", async () => { const res = await request(app) .options("/api/test") - .set("Origin", "https://preppilot-backend-abc123.onrender.com") + .set("Origin", "https://preppilot-attacker.onrender.com") .set("Access-Control-Request-Method", "GET"); - expect(res.status).toBe(200); - expect(res.headers["access-control-allow-origin"]).toBe( - "https://preppilot-backend-abc123.onrender.com" - ); + expect(res.status).toBe(403); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("returns 403 for an interview-prep-ai attacker subdomain", async () => { + const res = await request(app) + .options("/api/test") + .set("Origin", "https://interview-prep-ai-evil.onrender.com") + .set("Access-Control-Request-Method", "GET"); + + expect(res.status).toBe(403); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("returns 403 for a preppilot-backend attacker subdomain", async () => { + const res = await request(app) + .options("/api/test") + .set("Origin", "https://preppilot-backend-evil.onrender.com") + .set("Access-Control-Request-Method", "GET"); + + expect(res.status).toBe(403); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); }); }); diff --git a/backend/tests/loginRateLimit.unit.test.js b/backend/tests/loginRateLimit.unit.test.js new file mode 100644 index 00000000..d43d31ac --- /dev/null +++ b/backend/tests/loginRateLimit.unit.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import express from "express"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// POST /api/auth/login rate limiting (issue #922): +// loginLimiter (10 attempts / 15 min) must actually be wired to the login +// route and return 429 once the limit is exceeded. +// --------------------------------------------------------------------------- + +let app; +let routerStack; + +beforeAll(async () => { + const authRoutes = await import("../routes/authRoutes.js"); + const router = authRoutes.default ?? authRoutes; + routerStack = router.stack; + + app = express(); + app.use(express.json()); + app.use(router); +}); + +const loginLayer = () => + routerStack.find( + (l) => l.route && l.route.path === "/login" && l.route.methods.post + ); + +describe("POST /login middleware chain", () => { + it("registers the route", () => { + expect(loginLayer()).toBeTruthy(); + }); + + it("mounts the strict limiter plus validator plus controller", () => { + // Identity comparison across the CJS/ESM boundary is unreliable, so assert + // on the chain length: a loginLimiter + validateUserLogin + loginUser + // chain has three handlers, not the previous two-handler chain. + const handles = loginLayer().route.stack.map((s) => s.handle); + expect(handles.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("POST /login brute-force protection", () => { + it("accepts attempts under the limit then returns 429 on the 11th", async () => { + const statuses = []; + for (let i = 0; i <= 10; i++) { + const res = await request(app) + .post("/login") + .send({ email: `user${i}@example.com`, password: "short" }); + statuses.push(res.status); + } + // The first 10 attempts pass through to validation (400), proving the + // limiter is not pre-emptively blocking below its threshold. + for (let i = 0; i < 10; i++) { + expect(statuses[i]).toBe(400); + } + // The 11th attempt is rejected by loginLimiter (max: 10). + expect(statuses[10]).toBe(429); + }); +}); + diff --git a/backend/tests/problemSolver.unit.test.js b/backend/tests/problemSolver.unit.test.js new file mode 100644 index 00000000..ff801154 --- /dev/null +++ b/backend/tests/problemSolver.unit.test.js @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; + +const { + buildSolverPrompt, + parseSolverOutput, + extractSection, + SUPPORTED_LANGUAGES, + DEFAULT_LANGUAGE, +} = require("../utils/problemSolverParser.js"); +const problemSolverSchema = require("../validation/problemSolverSchema.js"); + +const SAMPLE_OUTPUT = [ + "## Approach", + "Use a hash map to track seen numbers while iterating.", + "## Steps", + "1. Initialize an empty map.", + "2. For each number, check if the complement exists.", + "## Complexity", + "Time: O(n), Space: O(n).", + "## Code", + "```python", + "def two_sum(nums, target):", + " seen = {}", + " for i, n in enumerate(nums):", + " if target - n in seen:", + " return [seen[target - n], i]", + " seen[n] = i", + "```", +].join("\n"); + +describe("buildSolverPrompt", () => { + it("includes the problem and defaults language to python", () => { + const prompt = buildSolverPrompt({ problem: "Two Sum" }); + expect(prompt).toContain("Two Sum"); + expect(prompt).toMatch(/written in python/); + expect(prompt).toContain("## Approach"); + expect(prompt).toContain("## Code"); + }); + + it("uses the requested language and includes constraints", () => { + const prompt = buildSolverPrompt({ + problem: "Two Sum", + language: "javascript", + constraints: "n <= 10^4", + }); + expect(prompt).toMatch(/written in javascript/); + expect(prompt).toContain("n <= 10^4"); + }); + + it("falls back to python for unknown languages", () => { + const prompt = buildSolverPrompt({ problem: "Two Sum", language: "cobol" }); + expect(prompt).toMatch(/written in python/); + }); +}); + +describe("parseSolverOutput", () => { + it("extracts all four sections", () => { + const result = parseSolverOutput(SAMPLE_OUTPUT); + expect(result.ok).toBe(true); + expect(result.sections.approach).toContain("hash map"); + expect(result.sections.steps).toContain("Initialize an empty map"); + expect(result.sections.complexity).toContain("O(n)"); + expect(result.sections.code).toContain("def two_sum"); + }); + + it("keeps the code fence intact", () => { + const result = parseSolverOutput(SAMPLE_OUTPUT); + expect(result.sections.code).toMatch(/```python/); + expect(result.sections.code).toMatch(/```\s*$/); + }); + + it("handles ### and bold-style headings", () => { + const text = [ + "### **Approach**", + "Brute force then optimize.", + "### **Code**", + "```py", + "print(1)", + "```", + ].join("\n"); + const result = parseSolverOutput(text); + expect(result.ok).toBe(true); + expect(result.sections.approach).toContain("Brute force"); + expect(result.sections.code).toContain("print(1)"); + }); + + it("returns ok=false and raw text when sections are missing", () => { + const result = parseSolverOutput("Just some text without sections."); + expect(result.ok).toBe(false); + expect(result.raw).toBe("Just some text without sections."); + expect(result.sections.approach).toBeUndefined(); + }); + + it("handles empty and non-string input", () => { + expect(parseSolverOutput("").ok).toBe(false); + expect(parseSolverOutput(null).ok).toBe(false); + expect(parseSolverOutput(undefined).ok).toBe(false); + }); + + it("stops a section at the next heading", () => { + const steps = extractSection(SAMPLE_OUTPUT, "steps"); + expect(steps).toContain("Initialize an empty map"); + expect(steps).not.toContain("complexity"); + }); +}); + +describe("problemSolverSchema", () => { + it("accepts a valid payload and defaults language", () => { + const parsed = problemSolverSchema.parse({ + problem: "Reverse a linked list", + }); + expect(parsed.problem).toBe("Reverse a linked list"); + expect(parsed.language).toBe(DEFAULT_LANGUAGE); + expect(parsed.constraints).toBe(""); + }); + + it("accepts an allowed language", () => { + expect(SUPPORTED_LANGUAGES).toContain("go"); + const parsed = problemSolverSchema.parse({ problem: "xyz", language: "go" }); + expect(parsed.language).toBe("go"); + }); + + it("rejects a missing problem", () => { + expect(() => problemSolverSchema.parse({})).toThrow(/Problem is required/); + }); + + it("rejects an empty problem", () => { + expect(() => problemSolverSchema.parse({ problem: "" })).toThrow( + /Problem is required/ + ); + }); + + it("rejects a too-short problem", () => { + expect(() => problemSolverSchema.parse({ problem: "ab" })).toThrow( + /at least 3 characters/ + ); + }); + + it("rejects an unsupported language", () => { + expect(() => problemSolverSchema.parse({ problem: "x", language: "cobol" })).toThrow( + /language must be one of/ + ); + }); +}); diff --git a/backend/tests/questionBank.unit.test.js b/backend/tests/questionBank.unit.test.js new file mode 100644 index 00000000..f126541a --- /dev/null +++ b/backend/tests/questionBank.unit.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../models/Session.js"); +vi.mock("../models/Question.js"); + +const Session = require("../models/Session.js"); +const Question = require("../models/Question.js"); +const { + getMyQuestions, +} = require("../controllers/questionController.js"); + +const OWN_SESSION = "507f1f77bcf86cd799439011"; +const FOREIGN_SESSION = "507f1f77bcf86cd799439022"; + +function makeReq(query = {}, userId = "u1") { + return { user: { _id: userId }, query }; +} + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +function leanChain(rows) { + return { + sort: () => ({ + skip: () => ({ + limit: () => ({ + populate: () => ({ + lean: () => Promise.resolve(rows), + }), + }), + }), + }), + }; +} + +function sessionChain(rows) { + return { + select: () => ({ + lean: () => Promise.resolve(rows), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + Session.find = vi.fn(); + Question.find = vi.fn(); + Question.countDocuments = vi.fn(); +}); + +describe("getMyQuestions", () => { + it("returns an empty list when the user has no sessions", async () => { + Session.find.mockReturnValue(sessionChain([])); + const res = makeRes(); + + await getMyQuestions(makeReq(), res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, questions: [] }) + ); + }); + + it("returns questions joined with session metadata and pagination", async () => { + Session.find.mockReturnValue(sessionChain([{ _id: OWN_SESSION }])); + const row = { + _id: "q1", + question: "What is polymorphism?", + answer: "Many forms", + isPinned: false, + session: { _id: OWN_SESSION, role: "SDE", topicsToFocus: ["oop"] }, + }; + Question.find.mockReturnValue(leanChain([row])); + Question.countDocuments.mockResolvedValue(1); + + const res = makeRes(); + await getMyQuestions(makeReq({ page: "1", limit: "20" }), res); + + expect(Question.find).toHaveBeenCalledWith({ + session: { $in: [OWN_SESSION] }, + }); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.questions[0].session.role).toBe("SDE"); + expect(body.pagination).toEqual({ + totalItems: 1, + totalPages: 1, + page: 1, + pageSize: 20, + hasNextPage: false, + }); + }); + + it("applies pinned, session and text-search filters", async () => { + Session.find.mockReturnValue(sessionChain([{ _id: OWN_SESSION }])); + Question.find.mockReturnValue(leanChain([])); + Question.countDocuments.mockResolvedValue(0); + + const res = makeRes(); + await getMyQuestions( + makeReq({ pinned: "true", sessionId: OWN_SESSION, q: "polymorphism" }), + res + ); + + const filter = Question.find.mock.calls[0][0]; + expect(filter.isPinned).toBe(true); + expect(filter.session).toBe(OWN_SESSION); + expect(filter.$or).toHaveLength(3); + expect(filter.$or[0].question).toEqual(new RegExp("polymorphism", "i")); + }); + + it("rejects an invalid sessionId format with 400", async () => { + Session.find.mockReturnValue(sessionChain([{ _id: OWN_SESSION }])); + const res = makeRes(); + + await getMyQuestions(makeReq({ sessionId: "not-an-objectid" }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(Question.find).not.toHaveBeenCalled(); + }); + + it("rejects a session that does not belong to the user with 403", async () => { + Session.find.mockReturnValue(sessionChain([{ _id: OWN_SESSION }])); + const res = makeRes(); + + await getMyQuestions(makeReq({ sessionId: FOREIGN_SESSION }), res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it("returns 500 when the query fails", async () => { + Session.find.mockReturnValue({ select: () => ({ lean: () => Promise.reject(new Error("boom")) }) }); + const res = makeRes(); + + await getMyQuestions(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: "Internal server error occurred", + }); + }); +}); diff --git a/backend/tests/registerEnumeration.unit.test.js b/backend/tests/registerEnumeration.unit.test.js new file mode 100644 index 00000000..dfdc2b19 --- /dev/null +++ b/backend/tests/registerEnumeration.unit.test.js @@ -0,0 +1,125 @@ +import { Module } from "node:module"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Register account-enumeration fix (issue #930): +// registering with an already-existing email must not return a distinct +// "already exists" error — the response is generic and the status is the same. +// +// authController.js is CommonJS and loads its deps via require(), which +// vitest's vi.mock cannot intercept. We shim Node's module loader instead so +// the real User model / bcrypt are never touched. +// --------------------------------------------------------------------------- + +const userMock = vi.hoisted(() => ({ + findOne: vi.fn(), + create: vi.fn(), +})); + +const testDoubles = new Map(); +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (testDoubles.has(request)) { + return testDoubles.get(request); + } + return originalLoad.call(this, request, parent, isMain); +}; + +const clearRequireCache = () => { + Object.keys(require.cache).forEach((key) => { + if ( + key.includes("controllers\\authController") || + key.includes("controllers/authController") || + key.includes("models\\User") || + key.includes("models/User") + ) { + delete require.cache[key]; + } + }); +}; + +let registerUser; + +beforeAll(async () => { + clearRequireCache(); + testDoubles.set("../models/User", { + findOne: userMock.findOne, + create: userMock.create, + }); + testDoubles.set("bcryptjs", { + hash: vi.fn(async () => "hashed_refresh_token"), + compare: vi.fn(), + }); + + const mod = await import("../controllers/authController.js"); + registerUser = mod.registerUser; +}); + +beforeEach(() => { + vi.stubEnv("JWT_SECRET", "test_secret"); + userMock.findOne.mockReset(); + userMock.create.mockReset(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const mockRes = () => { + const res = { statusCode: null, body: null }; + res.status = (code) => { + res.statusCode = code; + return res; + }; + res.json = (body) => { + res.body = body; + return res; + }; + res.cookie = () => res; + return res; +}; + +const req = (overrides = {}) => ({ + body: { name: "Test User", email: "new@example.com", password: "StrongPass123!" }, + ...overrides, +}); + +describe("registerUser — account enumeration", () => { + it("returns a generic 201 for an already-registered email (no tokens, no user data)", async () => { + userMock.findOne.mockResolvedValue({ _id: "existing-id" }); + + const res = mockRes(); + await registerUser(req(), res); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.message).toContain("already registered"); + expect(res.body.accessToken).toBeUndefined(); + expect(res.body._id).toBeUndefined(); + expect(userMock.create).not.toHaveBeenCalled(); + }); + + it("returns 201 with tokens for a brand-new email", async () => { + userMock.findOne.mockResolvedValue(null); + const fakeUser = { + _id: "new-id", + tokenVersion: 0, + name: "Test User", + email: "new@example.com", + profileImageUrl: null, + refreshTokenHash: null, + refreshTokenExpiresAt: null, + save: vi.fn(async function () { + return this; + }), + }; + userMock.create.mockResolvedValue(fakeUser); + + const res = mockRes(); + await registerUser(req(), res); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.accessToken).toBeTruthy(); + }); +}); diff --git a/backend/tests/sessionController.create.transaction.unit.test.js b/backend/tests/sessionController.create.transaction.unit.test.js new file mode 100644 index 00000000..e9a5119b --- /dev/null +++ b/backend/tests/sessionController.create.transaction.unit.test.js @@ -0,0 +1,147 @@ +import { Module } from "node:module"; +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// createSession transaction fix (issue #1442): the 201 response must be sent +// only after the transaction commits, and validation failures must return 4xx +// from the outer handler instead of `return res...` inside the withTransaction +// callback (which would resolve it and commit). +// +// sessionController.js is CommonJS, so we shim Node's module loader (same +// pattern as registerEnumeration.unit.test.js). +// --------------------------------------------------------------------------- + +const sessionMock = vi.hoisted(() => ({ + countDocuments: vi.fn(), + create: vi.fn(), +})); +const questionMock = vi.hoisted(() => ({ insertMany: vi.fn() })); +const mongooseMock = vi.hoisted(() => ({ startSession: vi.fn() })); + +const testDoubles = new Map(); +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (testDoubles.has(request)) { + return testDoubles.get(request); + } + return originalLoad.call(this, request, parent, isMain); +}; + +const clearRequireCache = () => { + Object.keys(require.cache).forEach((key) => { + if ( + key.includes("controllers\\sessionController") || + key.includes("controllers/sessionController") + ) { + delete require.cache[key]; + } + }); +}; + +let createSession; +let currentSession; + +const makeSessionObj = () => ({ + withTransaction: vi.fn(async (cb) => cb()), + endSession: vi.fn(async () => {}), +}); + +beforeAll(async () => { + clearRequireCache(); + testDoubles.set("mongoose", mongooseMock); + testDoubles.set("../models/Session", sessionMock); + testDoubles.set("../models/Question", questionMock); + + const mod = await import("../controllers/sessionController.js"); + createSession = mod.createSession; +}); + +beforeEach(() => { + sessionMock.countDocuments.mockReset(); + sessionMock.create.mockReset(); + questionMock.insertMany.mockReset(); + mongooseMock.startSession.mockReset(); + currentSession = makeSessionObj(); + mongooseMock.startSession.mockImplementation(async () => currentSession); +}); + +const mockRes = () => { + const res = { statusCode: 200, body: null }; + res.status = (code) => { + res.statusCode = code; + return res; + }; + res.json = (body) => { + res.body = body; + return res; + }; + return res; +}; + +const req = (overrides = {}) => ({ + user: { _id: "user-1" }, + body: { role: "Backend Engineer", experience: "3", topicsToFocus: [], description: "" }, + ...overrides, +}); + +describe("createSession — transaction boundary", () => { + it("returns 400 for a missing role without starting a transaction", async () => { + const res = mockRes(); + await createSession(req({ body: { role: " ", experience: "3" } }), res); + + expect(res.statusCode).toBe(400); + expect(mongooseMock.startSession).not.toHaveBeenCalled(); + }); + + it("returns 400 for a non-numeric experience", async () => { + const res = mockRes(); + await createSession(req({ body: { role: "Engineer", experience: "abc" } }), res); + + expect(res.statusCode).toBe(400); + expect(mongooseMock.startSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when the session limit is reached without starting a transaction", async () => { + sessionMock.countDocuments.mockResolvedValue(50); + + const res = mockRes(); + await createSession(req(), res); + + expect(res.statusCode).toBe(400); + expect(mongooseMock.startSession).not.toHaveBeenCalled(); + }); + + it("does not emit 201 when the transaction commit fails", async () => { + sessionMock.countDocuments.mockResolvedValue(0); + currentSession.withTransaction.mockRejectedValueOnce(new Error("commit failed")); + + const res = mockRes(); + await createSession(req(), res); + + expect(res.statusCode).toBe(500); + expect(res.statusCode).not.toBe(201); + expect(currentSession.endSession).toHaveBeenCalled(); + }); + + it("emits 201 with the created session only after the transaction commits", async () => { + sessionMock.countDocuments.mockResolvedValue(0); + const createdSession = { + _id: "s1", + role: "Backend Engineer", + questions: [], + save: vi.fn(async function () { + return this; + }), + }; + sessionMock.create.mockResolvedValue([createdSession]); + questionMock.insertMany.mockResolvedValue([]); + + const res = mockRes(); + await createSession(req(), res); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.session).toBe(createdSession); + expect(currentSession.endSession).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/sheetProgress.export.import.unit.test.js b/backend/tests/sheetProgress.export.import.unit.test.js new file mode 100644 index 00000000..363dea69 --- /dev/null +++ b/backend/tests/sheetProgress.export.import.unit.test.js @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../models/UserSheetProgress.js"); + +const UserSheetProgress = require("../models/UserSheetProgress.js"); +const { + exportProgress, + importProgress, +} = require("../controllers/userSheetProgressController.js"); +const { + normalizeProgressItems, + normalizeProgressItem, + clampPercentage, + buildBulkOps, + MAX_IMPORT_ITEMS, +} = require("../utils/sheetProgressImport.js"); + +function makeRes() { + const res = { + statusCode: 200, + headers: {}, + body: undefined, + setHeader(key, value) { + this.headers[key] = value; + }, + send(body) { + this.body = body; + }, + json(obj) { + this.body = obj; + }, + status(code) { + this.statusCode = code; + return this; + }, + }; + return res; +} + +describe("clampPercentage", () => { + it("clamps to the 0-100 range", () => { + expect(clampPercentage(250)).toBe(100); + expect(clampPercentage(-10)).toBe(0); + expect(clampPercentage(42)).toBe(42); + }); + + it("falls back to 0 for non-numeric input", () => { + expect(clampPercentage("abc")).toBe(0); + expect(clampPercentage(undefined)).toBe(0); + }); +}); + +describe("normalizeProgressItem", () => { + it("normalizes a valid entry", () => { + const result = normalizeProgressItem( + { sheetId: "sheet-1", followed: true, completedTopics: { "two-sum": true }, percentage: 42 }, + 0 + ); + expect(result.ok).toBe(true); + expect(result.value.sheetId).toBe("sheet-1"); + expect(result.value.followed).toBe(true); + expect(result.value.percentage).toBe(42); + }); + + it("trims and clamps loosely-typed fields", () => { + const result = normalizeProgressItem( + { sheetId: " sheet-2 ", followed: "yes", completedTopics: [1, 2], percentage: 250 }, + 0 + ); + expect(result.value.sheetId).toBe("sheet-2"); + expect(result.value.followed).toBe(false); + expect(result.value.completedTopics).toEqual({}); + expect(result.value.percentage).toBe(100); + }); + + it("rejects missing, non-string or oversized sheetIds", () => { + expect(normalizeProgressItem({ sheetId: "" }, 0).ok).toBe(false); + expect(normalizeProgressItem({ sheetId: 42 }, 0).ok).toBe(false); + expect(normalizeProgressItem({ sheetId: "x".repeat(101) }, 0).ok).toBe(false); + expect(normalizeProgressItem(null, 0).ok).toBe(false); + expect(normalizeProgressItem([1, 2], 0).ok).toBe(false); + }); +}); + +describe("normalizeProgressItems", () => { + it("returns an error for a non-array payload", () => { + const { items, errors } = normalizeProgressItems({}); + expect(items).toEqual([]); + expect(errors.length).toBeGreaterThan(0); + }); + + it("skips invalid entries and keeps valid ones", () => { + const { items, errors } = normalizeProgressItems([ + { sheetId: "a" }, + { bad: true }, + { sheetId: "b" }, + ]); + expect(items.length).toBe(2); + expect(errors.length).toBe(1); + }); + + it("rejects oversized payloads", () => { + const many = Array.from({ length: MAX_IMPORT_ITEMS + 1 }, () => ({ sheetId: "x" })); + const { items, errors } = normalizeProgressItems(many); + expect(items).toEqual([]); + expect(errors[0]).toContain("exceeds limit"); + }); +}); + +describe("buildBulkOps", () => { + it("creates upsert updateOne ops scoped to the user", () => { + const ops = buildBulkOps("u1", [ + { sheetId: "s1", followed: true, completedTopics: {}, percentage: 10 }, + ]); + expect(ops[0].updateOne.filter).toEqual({ userId: "u1", sheetId: "s1" }); + expect(ops[0].updateOne.upsert).toBe(true); + expect(ops[0].updateOne.update.$set.followed).toBe(true); + }); +}); + +describe("exportProgress controller", () => { + beforeEach(() => vi.clearAllMocks()); + + it("streams a JSON backup with safe fields", async () => { + const rows = [ + { + sheetId: "s1", + followed: true, + completedTopics: { a: true }, + percentage: 50, + updatedAt: new Date("2025-01-01T00:00:00.000Z"), + }, + ]; + UserSheetProgress.find = vi + .fn() + .mockReturnValue({ lean: () => Promise.resolve(rows) }); + + const req = { user: { _id: "u1" } }; + const res = makeRes(); + await exportProgress(req, res); + + expect(res.headers["Content-Disposition"]).toContain( + "sheet-progress-backup.json" + ); + const body = JSON.parse(res.body); + expect(body.count).toBe(1); + expect(body.items[0].sheetId).toBe("s1"); + expect(body.items[0].percentage).toBe(50); + }); + + it("returns 500 when the query fails", async () => { + UserSheetProgress.find = vi + .fn() + .mockReturnValue({ lean: () => Promise.reject(new Error("boom")) }); + const res = makeRes(); + await exportProgress({ user: { _id: "u1" } }, res); + expect(res.statusCode).toBe(500); + expect(res.body.success).toBe(false); + }); +}); + +describe("importProgress controller", () => { + beforeEach(() => vi.clearAllMocks()); + + it("bulk upserts normalized entries and reports counts", async () => { + UserSheetProgress.bulkWrite = vi + .fn() + .mockResolvedValue({ matchedCount: 2, upsertedCount: 1 }); + const req = { + user: { _id: "u1" }, + body: { items: [{ sheetId: "s1" }, { sheetId: "s2" }, { bad: 1 }] }, + }; + const res = makeRes(); + await importProgress(req, res); + + expect(UserSheetProgress.bulkWrite).toHaveBeenCalledOnce(); + expect(res.body.success).toBe(true); + expect(res.body.imported).toBe(2); + expect(res.body.skipped).toBe(1); + expect(res.body.created).toBe(1); + expect(res.body.updated).toBe(2); + }); + + it("returns 400 when nothing can be imported", async () => { + const req = { user: { _id: "u1" }, body: { items: [{ bad: 1 }] } }; + const res = makeRes(); + await importProgress(req, res); + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 400 when the payload is not an array", async () => { + const req = { user: { _id: "u1" }, body: { items: { sheetId: "s1" } } }; + const res = makeRes(); + await importProgress(req, res); + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); +}); diff --git a/backend/tests/sheetValidation.unit.test.js b/backend/tests/sheetValidation.unit.test.js new file mode 100644 index 00000000..cc71b770 --- /dev/null +++ b/backend/tests/sheetValidation.unit.test.js @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeAll } from "vitest"; + +const { + normalizeSheet, + normalizeSection, + normalizeSubtopic, + computeSheetStats, +} = require("../utils/sheetValidation.js"); + +function validSheet(overrides = {}) { + return { + id: "striver-sde", + title: "Striver SDE Sheet", + description: "The best sheet", + category: "dsa", + questions: 455, + followers: 1200, + sections: [ + { + title: "Arrays", + topics: [ + { + title: "1D Arrays", + subtopics: [ + { title: "Two Sum", difficulty: "Easy", status: "completed", links: { leetcode: "https://..." } }, + { title: "Max Subarray", difficulty: "Medium" }, + ], + }, + ], + }, + ], + ...overrides, + }; +} + +describe("normalizeSheet", () => { + it("normalizes a valid sheet and drops unknown fields", () => { + const { ok, errors, value } = normalizeSheet( + validSheet({ junk: "field", questions: "455.7", followers: -5 }) + ); + expect(ok).toBe(true); + expect(errors).toHaveLength(0); + expect(value.id).toBe("striver-sde"); + expect(value.questions).toBe(456); + expect(value.followers).toBe(0); + expect(value.junk).toBeUndefined(); + }); + + it("rejects a missing id", () => { + const { ok, errors } = normalizeSheet(validSheet({ id: "" })); + expect(ok).toBe(false); + expect(errors.some((e) => e.includes("id is required"))).toBe(true); + }); + + it("rejects an id with invalid characters", () => { + const { ok, errors } = normalizeSheet(validSheet({ id: "my sheet!" })); + expect(ok).toBe(false); + expect(errors.some((e) => e.includes("letters, digits"))).toBe(true); + }); + + it("rejects a missing title", () => { + const { ok, errors } = normalizeSheet(validSheet({ title: "" })); + expect(ok).toBe(false); + expect(errors.some((e) => e.includes("title is required"))).toBe(true); + }); + + it("rejects an unknown category", () => { + const { ok, errors } = normalizeSheet(validSheet({ category: "cat-herding" })); + expect(ok).toBe(false); + expect(errors.some((e) => e.includes("category"))).toBe(true); + }); + + it("falls back to 'general' for an empty category", () => { + const { ok, value } = normalizeSheet(validSheet({ category: "" })); + expect(ok).toBe(true); + expect(value.category).toBe("general"); + }); + + it("rejects non-object payloads", () => { + expect(normalizeSheet(null).ok).toBe(false); + expect(normalizeSheet("nope").ok).toBe(false); + expect(normalizeSheet([1, 2]).ok).toBe(false); + }); +}); + +describe("nested normalization", () => { + it("drops subtopics without a title", () => { + const sub = normalizeSubtopic({ difficulty: "Easy" }); + expect(sub).toBeNull(); + }); + + it("coerces difficulty and status to valid enums", () => { + const sub = normalizeSubtopic({ + title: "Two Sum", + difficulty: "Impossible", + status: "maybe", + }); + expect(sub.difficulty).toBe("Medium"); + expect(sub.status).toBe("not-started"); + }); + + it("drops invalid sections and topics", () => { + const section = normalizeSection({ title: "Arrays", completed: "bad", topics: [null, { title: "Valid" }] }); + expect(section.completed).toBe(0); + expect(section.topics).toHaveLength(1); + expect(section.topics[0].title).toBe("Valid"); + }); +}); + +describe("computeSheetStats", () => { + it("counts questions, sections and breaks down by difficulty/status", () => { + const { ok, value } = normalizeSheet(validSheet()); + expect(ok).toBe(true); + const stats = computeSheetStats(value); + expect(stats.sections).toBe(1); + expect(stats.questions).toBe(2); + expect(stats.byDifficulty).toEqual({ Easy: 1, Medium: 1, Hard: 0 }); + expect(stats.byStatus).toEqual({ "not-started": 1, "in-progress": 0, completed: 1 }); + }); +}); + +describe("sheet CRUD routes", () => { + let router; + + beforeAll(async () => { + const mod = await import("../routes/sheetJsonUpload.js"); + router = mod.default ?? mod; + }); + + function getLayerStack(method, path) { + const layer = router.stack.find( + (l) => l.route && l.route.path === path && l.route.methods[method.toLowerCase()] + ); + if (!layer) return null; + return layer.route.stack.map((s) => s.handle); + } + + function isProtect(handler) { + return typeof handler === "function" && handler.name === "protect"; + } + + it("registers POST /validate behind protect", () => { + const stack = getLayerStack("POST", "/validate"); + expect(stack).toBeTruthy(); + expect(stack.some(isProtect)).toBe(true); + }); + + it("registers PUT /:id behind protect", () => { + const stack = getLayerStack("PUT", "/:id"); + expect(stack).toBeTruthy(); + expect(stack.some(isProtect)).toBe(true); + }); + + it("registers DELETE /:id behind protect", () => { + const stack = getLayerStack("DELETE", "/:id"); + expect(stack).toBeTruthy(); + expect(stack.some(isProtect)).toBe(true); + }); + + it("registers POST /upload behind protect", () => { + const stack = getLayerStack("POST", "/upload"); + expect(stack).toBeTruthy(); + expect(stack.some(isProtect)).toBe(true); + }); + + it("keeps GET / and GET /:id public", () => { + expect(getLayerStack("GET", "/").some(isProtect)).toBe(false); + expect(getLayerStack("GET", "/:id").some(isProtect)).toBe(false); + }); + + it("places protect before the handler on PUT /:id", () => { + const stack = getLayerStack("PUT", "/:id"); + const protectIndex = stack.findIndex(isProtect); + expect(protectIndex).toBeGreaterThanOrEqual(0); + expect(protectIndex).toBeLessThan(stack.length - 1); + }); +}); diff --git a/backend/tests/uploadMiddleware.magicBytes.unit.test.js b/backend/tests/uploadMiddleware.magicBytes.unit.test.js new file mode 100644 index 00000000..a3b97a0e --- /dev/null +++ b/backend/tests/uploadMiddleware.magicBytes.unit.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Profile-image upload hardening (issue #920): +// - content is validated by magic bytes, never the client-supplied mimetype +// - the stored extension is server-decided from the detected type +// - /uploads static serving forces nosniff + attachment for non-images +// --------------------------------------------------------------------------- + +let resolveImageFileName; +let validateImageUpload; +let uploadsStaticHeaders; + +beforeEach(async () => { + const mod = await import("../middlewares/uploadMiddleware.js"); + resolveImageFileName = mod.resolveImageFileName; + validateImageUpload = mod.validateImageUpload; + uploadsStaticHeaders = mod.uploadsStaticHeaders; +}); + +const makeFile = (buffer, originalname) => ({ + buffer, + originalname, +}); + +const makeRes = () => { + const res = { status: vi.fn(), json: vi.fn(), setHeader: vi.fn() }; + res.status.mockReturnValue(res); + return res; +}; + +describe("resolveImageFileName — content-based detection", () => { + it("returns a .png filename for real PNG magic bytes", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]); + const name = await resolveImageFileName(makeFile(png, "evil.html")); + expect(name).toBeTruthy(); + expect(name.endsWith(".png")).toBe(true); + // attacker extension is not preserved + expect(name).not.toContain(".html"); + }); + + it("returns a .jpg filename for real JPEG magic bytes", async () => { + const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); + const name = await resolveImageFileName(makeFile(jpg, "photo.svg")); + expect(name.endsWith(".jpg")).toBe(true); + }); + + it("returns null for HTML content masquerading as an image", async () => { + const html = Buffer.from(""); + const name = await resolveImageFileName(makeFile(html, "x.png")); + expect(name).toBeNull(); + }); + + it("returns null for an SVG (script-capable) payload", async () => { + const svg = Buffer.from(''); + const name = await resolveImageFileName(makeFile(svg, "image.svg")); + expect(name).toBeNull(); + }); +}); + +describe("validateImageUpload middleware", () => { + it("returns 400 for non-image content and does not call next", async () => { + const req = { + file: makeFile(Buffer.from("not an image"), "profile.png"), + }; + const res = makeRes(); + const next = vi.fn(); + + await validateImageUpload(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 400 when no file was uploaded", async () => { + const req = {}; + const res = makeRes(); + const next = vi.fn(); + + await validateImageUpload(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe("uploadsStaticHeaders — static serving hardening", () => { + it("sets nosniff on every response", () => { + const res = { setHeader: vi.fn() }; + uploadsStaticHeaders(res, "C:\\uploads\\avatar.png"); + expect(res.setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + }); + + it("serves images inline without Content-Disposition", () => { + const res = { setHeader: vi.fn() }; + uploadsStaticHeaders(res, "C:\\uploads\\avatar.jpg"); + expect(res.setHeader).not.toHaveBeenCalledWith("Content-Disposition", "attachment"); + }); + + it("forces attachment download for non-image files like HTML", () => { + const res = { setHeader: vi.fn() }; + uploadsStaticHeaders(res, "C:\\uploads\\1740000000000-evil.html"); + expect(res.setHeader).toHaveBeenCalledWith("Content-Disposition", "attachment"); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/octet-stream"); + }); +}); diff --git a/backend/tests/userToJSON.unit.test.js b/backend/tests/userToJSON.unit.test.js new file mode 100644 index 00000000..71adf36b --- /dev/null +++ b/backend/tests/userToJSON.unit.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// --------------------------------------------------------------------------- +// GET /api/auth/profile secret redaction (issue #923): the User schema's +// toJSON transform must never serialize password / refresh-token / email +// verification secrets, which were previously leaked by res.json(user). +// --------------------------------------------------------------------------- + +let User; + +beforeAll(async () => { + const mod = await import("../models/User.js"); + User = mod.default ?? mod; +}); + +const buildUser = () => + new User({ + name: "Test User", + email: "test@example.com", + password: "hashed-super-secret", + profileImageUrl: "https://example.com/avatar.png", + refreshTokenHash: "refresh-hash", + refreshTokenExpiresAt: new Date(), + tokenVersion: 3, + firstName: "Test", + lastName: "User", + bio: "hello", + visibility: "Public", + prepPilotId: "testpilot123", + emailVerificationToken: "verification-token", + emailVerificationExpires: new Date(), + isEmailVerified: true, + }); + +describe("User toJSON transform", () => { + it("drops every secret field from toJSON()", () => { + const json = buildUser().toJSON(); + + expect(json.password).toBeUndefined(); + expect(json.refreshTokenHash).toBeUndefined(); + expect(json.refreshTokenExpiresAt).toBeUndefined(); + expect(json.emailVerificationToken).toBeUndefined(); + expect(json.emailVerificationExpires).toBeUndefined(); + expect(json.tokenVersion).toBeUndefined(); + }); + + it("keeps the public profile fields", () => { + const json = buildUser().toJSON(); + + expect(json.name).toBe("Test User"); + expect(json.email).toBe("test@example.com"); + expect(json.profileImageUrl).toBe("https://example.com/avatar.png"); + expect(json.firstName).toBe("Test"); + expect(json.bio).toBe("hello"); + expect(json.visibility).toBe("Public"); + expect(json.prepPilotId).toBe("testpilot123"); + }); + + it("JSON.stringify (the res.json path) also strips secrets", () => { + const str = JSON.stringify(buildUser()); + expect(str).not.toContain("refreshTokenHash"); + expect(str).not.toContain("emailVerificationToken"); + expect(str).not.toContain("hashed-super-secret"); + }); +}); diff --git a/backend/utils/problemSolverParser.js b/backend/utils/problemSolverParser.js new file mode 100644 index 00000000..380c538b --- /dev/null +++ b/backend/utils/problemSolverParser.js @@ -0,0 +1,101 @@ +const DEFAULT_LANGUAGE = "python"; +const SUPPORTED_LANGUAGES = [ + "python", + "javascript", + "typescript", + "java", + "cpp", + "c", + "go", + "rust", + "ruby", + "swift", +]; + +const SECTION_ALIASES = { + approach: ["approach", "approach / intuition", "intuition", "solution idea"], + steps: ["steps", "algorithm", "step-by-step", "solution"], + complexity: ["complexity", "time & space complexity", "time and space complexity", "complexity analysis"], + code: ["code", "implementation", "solution code"], +}; + +function buildSolverPrompt({ problem, language = DEFAULT_LANGUAGE, constraints = "" }) { + const lang = SUPPORTED_LANGUAGES.includes(language) ? language : DEFAULT_LANGUAGE; + const constraintsBlock = constraints + ? `\nConstraints:\n${constraints}` + : ""; + + return [ + `You are an expert coding interview tutor. Solve the following problem and return your answer as plain markdown using EXACTLY these four sections in order:`, + ``, + `## Approach`, + `## Steps`, + `## Complexity`, + `## Code`, + ``, + `Rules:`, + `- Approach: explain the optimal strategy and why it works, 1 short paragraph plus the key idea.`, + `- Steps: a numbered list of the algorithm.`, + `- Complexity: state Time and Space complexity with a brief justification.`, + `- Code: a complete, runnable solution written in ${lang}. Wrap it in a single fenced code block tagged with the language.`, + ``, + `Problem:`, + `${problem}`, + constraintsBlock, + ].join("\n"); +} + +function extractSection(text, heading) { + const headingPattern = new RegExp( + `(?:#{1,3}\\s*\\**|\\**)[ ]*${heading}[ ]*\\**(?:\\s*:)?`, + "i" + ); + const match = text.match(headingPattern); + if (!match) return null; + + const start = match.index + match[0].length; + const next = text.slice(start).match( + /(?:^|\n)(?:#{1,3}\s*\**)[ ]*[A-Z][^#\n]*\**:?(?=\n|$)/ + ); + const end = next ? next.index : text.length; + + let section = text.slice(start, start + end).trim(); + section = section.replace(/\n+$/, ""); + return section || null; +} + +function parseSolverOutput(text) { + if (!text || typeof text !== "string") { + return { ok: false, raw: "", sections: null }; + } + + const clean = text.replace(/\r\n/g, "\n").trim(); + const sections = {}; + + for (const [key, aliases] of Object.entries(SECTION_ALIASES)) { + for (const alias of aliases) { + const found = extractSection(clean, alias); + if (found) { + sections[key] = found; + break; + } + } + } + + const hasApproach = Boolean(sections.approach); + const hasCode = Boolean(sections.code); + + return { + ok: hasApproach && hasCode, + raw: clean, + sections, + }; +} + +module.exports = { + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGES, + buildSolverPrompt, + extractSection, + parseSolverOutput, +}; diff --git a/backend/utils/sendEmail.js b/backend/utils/sendEmail.js index 6526ea40..f3f65992 100644 --- a/backend/utils/sendEmail.js +++ b/backend/utils/sendEmail.js @@ -5,7 +5,7 @@ const nodemailer = require("nodemailer"); * Supports "gmail", "ethereal", or any custom SMTP provider. * Set EMAIL_SERVICE in .env to switch between providers. */ -console.log("EMAIL_SERVICE:", process.env.EMAIL_SERVICE); + const createTransporter = () => { const service = process.env.EMAIL_SERVICE?.toLowerCase(); @@ -59,7 +59,6 @@ const transporter = createTransporter(); * @param {string} verificationUrl - Full URL with token for email verification. */ const sendVerificationEmail = async (toEmail, verificationUrl) => { - console.log("Attempting SMTP connection..."); await transporter.sendMail({ from: `"PrepPilot" <${process.env.EMAIL_USER}>`, to: toEmail, @@ -80,7 +79,6 @@ const sendVerificationEmail = async (toEmail, verificationUrl) => { `, }); - console.log("SMTP verified"); }; module.exports = { sendVerificationEmail }; \ No newline at end of file diff --git a/backend/utils/sheetProgressImport.js b/backend/utils/sheetProgressImport.js new file mode 100644 index 00000000..242cf35c --- /dev/null +++ b/backend/utils/sheetProgressImport.js @@ -0,0 +1,89 @@ +const MAX_IMPORT_ITEMS = 500; +const MAX_SHEET_ID_LENGTH = 100; + +function clampPercentage(value) { + const num = Number(value); + if (!Number.isFinite(num)) return 0; + return Math.max(0, Math.min(100, Math.round(num * 100) / 100)); +} + +function normalizeCompletedTopics(value) { + if ( + value != null && + typeof value === "object" && + !Array.isArray(value) + ) { + return value; + } + return {}; +} + +function normalizeProgressItem(item, index) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return { ok: false, index, reason: "entry is not an object" }; + } + const sheetId = typeof item.sheetId === "string" ? item.sheetId.trim() : ""; + if (!sheetId || sheetId.length > MAX_SHEET_ID_LENGTH) { + return { ok: false, index, reason: "sheetId is missing or too long" }; + } + return { + ok: true, + index, + value: { + sheetId, + followed: item.followed === true, + completedTopics: normalizeCompletedTopics(item.completedTopics), + percentage: clampPercentage(item.percentage), + }, + }; +} + +function normalizeProgressItems(items) { + if (!Array.isArray(items)) { + return { items: [], errors: ["payload.items must be an array"] }; + } + if (items.length > MAX_IMPORT_ITEMS) { + return { + items: [], + errors: [`payload.items exceeds limit of ${MAX_IMPORT_ITEMS}`], + }; + } + + const normalized = []; + const errors = []; + items.forEach((item, index) => { + const result = normalizeProgressItem(item, index); + if (result.ok) { + normalized.push(result.value); + } else { + errors.push(`entry at index ${result.index}: ${result.reason}`); + } + }); + return { items: normalized, errors }; +} + +function buildBulkOps(userId, items) { + return items.map((item) => ({ + updateOne: { + filter: { userId, sheetId: item.sheetId }, + update: { + $set: { + followed: item.followed, + completedTopics: item.completedTopics, + percentage: item.percentage, + }, + }, + upsert: true, + }, + })); +} + +module.exports = { + MAX_IMPORT_ITEMS, + MAX_SHEET_ID_LENGTH, + buildBulkOps, + clampPercentage, + normalizeCompletedTopics, + normalizeProgressItem, + normalizeProgressItems, +}; diff --git a/backend/utils/sheetValidation.js b/backend/utils/sheetValidation.js new file mode 100644 index 00000000..9c1d07a1 --- /dev/null +++ b/backend/utils/sheetValidation.js @@ -0,0 +1,158 @@ +const SHEET_ID_PATTERN = /^[a-zA-Z0-9-_]+$/; +const DIFFICULTIES = ["Easy", "Medium", "Hard"]; +const STATUSES = ["not-started", "in-progress", "completed"]; +const CATEGORIES = ["general", "dsa", "aptitude", "system-design"]; + +function clampNonNegativeInt(value, fallback = 0) { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.max(0, Math.round(num)); +} + +function asString(value, maxLength = 500) { + if (typeof value !== "string") return ""; + return value.trim().slice(0, maxLength); +} + +function normalizeSubtopic(subtopic) { + if (!subtopic || typeof subtopic !== "object" || Array.isArray(subtopic)) { + return null; + } + const title = asString(subtopic.title, 200); + if (!title) return null; + + let difficulty = asString(subtopic.difficulty, 20) || "Medium"; + if (!DIFFICULTIES.includes(difficulty)) difficulty = "Medium"; + + let status = asString(subtopic.status, 20) || "not-started"; + if (!STATUSES.includes(status)) status = "not-started"; + + const links = subtopic.links && typeof subtopic.links === "object" + ? { + gfg: asString(subtopic.links.gfg, 1000), + leetcode: asString(subtopic.links.leetcode, 1000), + youtube: asString(subtopic.links.youtube, 1000), + } + : {}; + + return { title, difficulty, status, links }; +} + +function normalizeTopic(topic) { + if (!topic || typeof topic !== "object" || Array.isArray(topic)) { + return null; + } + const title = asString(topic.title, 200); + if (!title) return null; + + const subtopics = Array.isArray(topic.subtopics) + ? topic.subtopics.map(normalizeSubtopic).filter(Boolean) + : []; + + return { + title, + completed: clampNonNegativeInt(topic.completed), + total: clampNonNegativeInt(topic.total), + subtopics, + }; +} + +function normalizeSection(section) { + if (!section || typeof section !== "object" || Array.isArray(section)) { + return null; + } + const title = asString(section.title, 200); + if (!title) return null; + + const topics = Array.isArray(section.topics) + ? section.topics.map(normalizeTopic).filter(Boolean) + : []; + + return { + title, + completed: clampNonNegativeInt(section.completed), + total: clampNonNegativeInt(section.total), + topics, + }; +} + +function normalizeSheet(raw) { + const errors = []; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, errors: ["sheet payload must be an object"] }; + } + + const id = asString(raw.id, 100); + if (!id) errors.push("id is required"); + else if (!SHEET_ID_PATTERN.test(id)) { + errors.push("id may only contain letters, digits, dashes and underscores"); + } + + const title = asString(raw.title, 300); + if (!title) errors.push("title is required"); + + let category = asString(raw.category, 50); + if (category && !CATEGORIES.includes(category)) { + errors.push(`category must be one of: ${CATEGORIES.join(", ")}`); + category = ""; + } + + const sections = Array.isArray(raw.sections) + ? raw.sections.map(normalizeSection).filter(Boolean) + : []; + + return { + ok: errors.length === 0, + errors, + value: { + id, + title, + description: asString(raw.description, 2000), + followers: clampNonNegativeInt(raw.followers), + questions: clampNonNegativeInt(raw.questions), + category: category || "general", + sections, + }, + }; +} + +function computeSheetStats(sheet) { + let questions = 0; + let subtopicCount = 0; + const byDifficulty = { Easy: 0, Medium: 0, Hard: 0 }; + const byStatus = { "not-started": 0, "in-progress": 0, completed: 0 }; + + for (const section of sheet.sections || []) { + for (const topic of section.topics || []) { + for (const subtopic of topic.subtopics || []) { + subtopicCount += 1; + if (DIFFICULTIES.includes(subtopic.difficulty)) { + byDifficulty[subtopic.difficulty] += 1; + } + if (STATUSES.includes(subtopic.status)) { + byStatus[subtopic.status] += 1; + } + } + } + } + + questions = subtopicCount; + return { + questions, + sections: (sheet.sections || []).length, + byDifficulty, + byStatus, + }; +} + +module.exports = { + CATEGORIES, + DIFFICULTIES, + STATUSES, + SHEET_ID_PATTERN, + computeSheetStats, + normalizeSection, + normalizeSheet, + normalizeSubtopic, + normalizeTopic, +}; diff --git a/backend/validation/aiPromptSchema.js b/backend/validation/aiPromptSchema.js index c3e7d940..52475639 100644 --- a/backend/validation/aiPromptSchema.js +++ b/backend/validation/aiPromptSchema.js @@ -28,7 +28,8 @@ const safeString = z.string().refine( const aiPromptSchema = z.object({ prompt: safeString.min(1, "Prompt is required").max(5000, "Prompt must be under 5000 characters"), - systemInstruction: z.string().optional(), + // systemInstruction is intentionally NOT part of the request contract — the + // server owns the model persona (see aiRoutes.js SYSTEM_INSTRUCTION). history: z .array( z.object({ @@ -36,6 +37,7 @@ const aiPromptSchema = z.object({ text: z.string(), }) ) + .max(20, "Conversation history is too large") .optional(), role: safeString.min(2).max(50).optional(), topic: safeString.min(2).max(100).optional(), diff --git a/backend/validation/problemSolverSchema.js b/backend/validation/problemSolverSchema.js new file mode 100644 index 00000000..cda5db84 --- /dev/null +++ b/backend/validation/problemSolverSchema.js @@ -0,0 +1,30 @@ +const { z } = require("zod"); +const { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } = require("../utils/problemSolverParser"); + +const problemSolverSchema = z.object({ + problem: z + .string() + .trim() + .min(1, "Problem is required") + .max(5000, "Problem must be under 5000 characters") + .refine((v) => v.length >= 3, "Problem must be at least 3 characters") + .default(""), + language: z + .string() + .trim() + .default(DEFAULT_LANGUAGE) + .refine( + (v) => SUPPORTED_LANGUAGES.includes(v), + `language must be one of: ${SUPPORTED_LANGUAGES.join(", ")}` + ), + constraints: z + .string() + .trim() + .max(2000, "Constraints must be under 2000 characters") + .default(""), +}).refine((d) => typeof d.problem === "string" && d.problem.trim().length > 0, { + message: "Problem is required", + path: ["problem"], +}); + +module.exports = problemSolverSchema; diff --git a/frontend/Vitest.config.js b/frontend/Vitest.config.js new file mode 100644 index 00000000..a41470cb --- /dev/null +++ b/frontend/Vitest.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + }, +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2fe32b8c..9b09f893 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "react-router-dom": "^7.8.2", "react-split": "^2.0.14", "react-syntax-highlighter": "^15.6.6", - "recharts": "^3.10.1", + "recharts": "^2.15.3", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.12" }, @@ -1060,32 +1060,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", @@ -1418,18 +1392,6 @@ "win32" ] }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@tailwindcss/node": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", @@ -1907,12 +1869,6 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2577,6 +2533,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -2666,17 +2632,6 @@ "node": ">= 0.4" } }, - "node_modules/es-toolkit": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", - "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks", - "tests/types" - ] - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2930,9 +2885,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, "node_modules/extend": { @@ -2948,6 +2903,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3505,16 +3469,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", - "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4029,6 +3983,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5592,29 +5552,6 @@ "@types/unist": "*" } }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5663,6 +5600,21 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-split": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.14.tgz", @@ -5693,51 +5645,61 @@ "react": ">= 0.14.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/recharts": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", - "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", "license": "MIT", - "workspaces": [ - "www" - ], "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^11.1.8", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" }, "engines": { - "node": ">=18" + "node": ">=14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" + "dependencies": { + "decimal.js-light": "^2.4.1" } }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/refractor": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", @@ -5844,12 +5806,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6359,15 +6315,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -6418,9 +6365,9 @@ "license": "MIT" }, "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/frontend/package.json b/frontend/package.json index fc6be1a8..6a38a3d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest watch" }, "dependencies": { "@monaco-editor/react": "^4.7.0", @@ -25,7 +27,7 @@ "react-router-dom": "^7.8.2", "react-split": "^2.0.14", "react-syntax-highlighter": "^15.6.6", - "recharts": "^3.10.1", + "recharts": "^2.15.3", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.12" }, @@ -34,11 +36,14 @@ "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.0.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "vite": "^7.1.2" + "jsdom": "^29.1.1", + "vite": "^7.1.2", + "vitest": "^4.1.10" }, "overrides": { "tar": "7.5.19" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0e573ddc..29c6b390 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import ResumeAnalyzer from "./pages/ResumeBuilder/ResumeAnalyzer"; import InterviewExperiences from "./pages/InterviewExperiences/InterviewExperiences"; import TermsandConditions from "./pages/Terms/TermsandConditions"; import ProjectIdeas from "./pages/ProjectIdeas/ProjectIdeas"; +import ProjectRoadmap from "./pages/ProjectRoadmap/ProjectRoadmap"; import RepositoryHive from "./pages/OpenSource/RepositoryHive"; import OSSBlog from "./pages/OpenSource/OSSBlog"; import OpenSourceEvents from "./pages/OpenSource/OpenSourceEvents"; @@ -28,8 +29,11 @@ import NotFound from "./pages/NotFound"; import PrivacyPolicy from "./pages/Terms/PrivacyPolicy"; import FreeCourses from "./pages/FreeCourses/FreeCourses"; import SpacedRepetitionPage from "./pages/SpacedRepetition/SpacedRepetitionPage"; +import BehavioralCoach from "./pages/BehavioralCoach/BehavioralCoach"; import DailyCodingChallenge from "./pages/DailyCodingChallenge/DailyCodingChallenge"; +import ProblemSolver from "./pages/ProblemSolver/ProblemSolver"; import Analytics from "./pages/Analytics"; +import QuestionBank from "./pages/QuestionBank/QuestionBank"; import RevisionPlanner from "./pages/RevisionPlanner/RevisionPlanner"; // Route-level code-splitting: each page is fetched only when its route is @@ -173,10 +177,22 @@ const App = () => { - + + + } > + + + + + + } + /> { } /> + + + + + + } + /> { } /> + + + + } + /> { } /> + + + + } + /> diff --git a/frontend/src/components/Compiler.jsx b/frontend/src/components/Compiler.jsx index 52f0258d..284bfd31 100644 --- a/frontend/src/components/Compiler.jsx +++ b/frontend/src/components/Compiler.jsx @@ -52,7 +52,8 @@ int main() { } ); - const result = await response.json(); + if (!response.ok) throw new Error("Request failed"); +const result = await response.json(); const finalOutput = result.stdout || result.stderr || diff --git a/frontend/src/components/Layouts/Sidebar.jsx b/frontend/src/components/Layouts/Sidebar.jsx index acb0b9c9..e66c7371 100644 --- a/frontend/src/components/Layouts/Sidebar.jsx +++ b/frontend/src/components/Layouts/Sidebar.jsx @@ -11,7 +11,8 @@ import { Code2, Target, Settings, HelpCircle, User as UserIcon, LogOut, Menu, X, FileText, Zap, MessageSquare, Lightbulb, ChevronUp, ChevronDown, Github, BookOpen, BookMarked, CalendarDays, ScrollText, - Grid3x3, GraduationCap, Calculator, RotateCcw, Sparkles, + Grid3x3, GraduationCap, Calculator, RotateCcw, Sparkles, Map, + Brain, } from "lucide-react"; /* ── NAV DEFINITION ──────────────────────────────────────────────────────── */ @@ -47,6 +48,7 @@ const NAV_ITEMS = [ isHeader: true, items: [ { id: "coding-sheets", title: "DSA Master Sheets", path: "/coding-sheets", icon: Code2 }, + { id: "problem-solver", title: "AI Problem Solver", path: "/problem-solver", icon: BrainCircuit }, ], }, { @@ -58,6 +60,7 @@ const NAV_ITEMS = [ { id: "role-prep", title: "Role-Specific Prep", path: "/role-prep", icon: Briefcase }, { id: "spaced-repetition", title: "Spaced Repetition", path: "/spaced-repetition", icon: RotateCcw }, { id: "assessment", title: "Skill Assessment", path: "/assessment", icon: Target }, + { id: "question-bank", title: "Question Bank", path: "/question-bank", icon: Brain }, { id: "interview-experiences", title: "Interview Experiences", path: "/interview-experiences", icon: MessageSquare }, ], }, @@ -77,6 +80,7 @@ const NAV_ITEMS = [ isHeader: true, items: [ { id: "project-ideas", title: "Project Ideas", path: "/project-ideas", icon: Lightbulb }, + { id: "project-roadmap", title: "Roadmap Assistant", path: "/project-roadmap", icon: Map }, ], }, { @@ -145,7 +149,7 @@ const Sidebar = () => { user?.email?.charAt(0)?.toUpperCase() || "U"; const handleLogout = async () => { - try { await axiosInstance.post(API_PATHS.AUTH.LOGOUT); } catch {} + try { await axiosInstance.post(API_PATHS.AUTH.LOGOUT); } catch (err) { /* ignore */ } finally { localStorage.clear(); sessionStorage.clear(); clearUser(); navigate("/"); diff --git a/frontend/src/components/SheetDetailsPage.jsx b/frontend/src/components/SheetDetailsPage.jsx index ec15672c..889934e0 100644 --- a/frontend/src/components/SheetDetailsPage.jsx +++ b/frontend/src/components/SheetDetailsPage.jsx @@ -114,7 +114,7 @@ function SheetDetail() { setFollowed(progressData.followed || false); setCompletedTopics(progressData.completedTopics || {}); } - } catch {} + } catch (err) { /* ignore parse error */ } } setLoading(false); @@ -159,7 +159,7 @@ function SheetDetail() { }).then(() => refreshSheetProgress?.()).catch(err => console.error("Failed to sync progress to backend:", err)); }, 500); return () => clearTimeout(saveToStorage); - }, [completedTopics, followed]); + }, [completedTopics, followed, completedCount, id, refreshSheetProgress, totalSubtopics]); const handleCompleteToggle = useCallback((sectionIdx, topicIdx, subIdx) => { if (!followed) return; diff --git a/frontend/src/context/themeContext.jsx b/frontend/src/context/themeContext.jsx index 9d6b444c..407faaeb 100644 --- a/frontend/src/context/themeContext.jsx +++ b/frontend/src/context/themeContext.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useState, useEffect, useContext } from "react"; export const ThemeContext = createContext(); diff --git a/frontend/src/context/userContext.jsx b/frontend/src/context/userContext.jsx index b200c6dd..947fa69f 100644 --- a/frontend/src/context/userContext.jsx +++ b/frontend/src/context/userContext.jsx @@ -1,14 +1,15 @@ +/* eslint-disable react-refresh/only-export-components */ import React, { createContext, useState, useEffect } from "react"; import axiosInstance from "../utils/axiosinstance"; import { API_PATHS } from "../utils/apiPaths"; import toast from "react-hot-toast"; + import { isMockAuthEnabled, getMockUser, clearMockUser, } from "../utils/mockAuth"; - export const UserContext = createContext(); export const UserProvider = ({ children }) => { @@ -48,7 +49,6 @@ export const UserProvider = ({ children }) => { if (status === 401 || status === 403) { clearUser(); } - setLoading(false); return; } finally { setLoading(false); @@ -61,6 +61,7 @@ export const UserProvider = ({ children }) => { } }; fetchUser(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const updateUser = (userData) => { diff --git a/frontend/src/hooks/useMemoryMatch.js b/frontend/src/hooks/useMemoryMatch.js index 9501cf3b..e82fe7ad 100644 --- a/frontend/src/hooks/useMemoryMatch.js +++ b/frontend/src/hooks/useMemoryMatch.js @@ -6,6 +6,7 @@ import { playVictorySound, initAudioContext, } from "../utils/matchAudio"; +import { getDailySeed } from "../utils/dailySeed"; // ─── Game Difficulty Specifications ───────────────────────────────────────────── export const DIFFICULTY_CONFIGS = { @@ -178,7 +179,7 @@ export const useMemoryMatch = () => { { r: 6, c: 6, p: 18 }, ]; // If daily challenge is enabled, choose seed-based layout, else random - const dateInt = new Date().getFullYear() * 10000 + (new Date().getMonth() + 1) * 100 + new Date().getDate(); + const dateInt = getDailySeed(); const index = challengeMode ? (dateInt % options.length) : Math.floor(Math.random() * options.length); rows = options[index].r; @@ -197,7 +198,7 @@ export const useMemoryMatch = () => { let shuffledDeck = []; if (challengeMode) { // Create seed based on calendar date (YYYYMMDD) - const dateSeed = new Date().getFullYear() * 10000 + (new Date().getMonth() + 1) * 100 + new Date().getDate(); + const dateSeed = getDailySeed(); shuffledDeck = seedShuffle(deckPool, dateSeed); } else { shuffledDeck = randomShuffle(deckPool); diff --git a/frontend/src/hooks/usePatternMatrix.js b/frontend/src/hooks/usePatternMatrix.js index 5ecf41d9..ee729c97 100644 --- a/frontend/src/hooks/usePatternMatrix.js +++ b/frontend/src/hooks/usePatternMatrix.js @@ -342,7 +342,7 @@ export const usePatternMatrix = () => { }, 1500); } } - }, [phase, paused, selected, wrongClicks, targets, gridSize, difficulty, level, streak, lives, score, startCountdown, clearTimers]); + }, [phase, paused, selected, wrongClicks, targets, gridSize, difficulty, level, streak, lives, score, startCountdown]); // ─── Pause & Resume Utilities ──────────────────────────────────────────────── const pauseGame = useCallback(() => { diff --git a/frontend/src/pages/AIInterviewPreparationHabitTracker/AIInterviewPreparationHabitTracker.js b/frontend/src/pages/AIInterviewPreparationHabitTracker/AIInterviewPreparationHabitTracker.js new file mode 100644 index 00000000..a6fd3bc2 --- /dev/null +++ b/frontend/src/pages/AIInterviewPreparationHabitTracker/AIInterviewPreparationHabitTracker.js @@ -0,0 +1,495 @@ +import React, { useState } from "react"; +import { + Brain, + CalendarCheck, + Flame, + TrendingUp, + Bell, + CheckCircle2, + Activity, +} from "lucide-react"; + +const AIInterviewPreparationHabitTracker = () => { + + const [stats] = useState({ + streak: 18, + monthlyStreak: 52, + completion: 84, + readiness: 89, + }); + + const [habits, setHabits] = useState([ + { + title: "Solve 5 DSA Problems", + completed: true, + }, + { + title: "Revise Core Subjects", + completed: false, + }, + { + title: "Practice HR Questions", + completed: true, + }, + { + title: "Attend Mock Interview", + completed: false, + }, + { + title: "Review Flashcards", + completed: true, + }, + ]); + + const toggleHabit = (index) => { + const updated = [...habits]; + updated[index].completed = !updated[index].completed; + setHabits(updated); + }; + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Interview Preparation Habit Tracker + +

+ +

+ + Build consistent interview preparation habits with + AI-powered tracking, streak monitoring, and + personalized study recommendations. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Daily Streak + +

+ +

+ + 🔥 {stats.streak} + +

+ +
+ +
+ + + +

+ + Monthly Streak + +

+ +

+ + {stats.monthlyStreak} + +

+ +
+ +
+ + + +

+ + Completion + +

+ +

+ + {stats.completion}% + +

+ +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + {/* Habit Checklist */} + +
+ +

+ + Today's Preparation Habits + +

+ +
+ + {habits.map((habit, index) => ( + +
+ + + + {habit.title} + + + + toggleHabit(index)} + className="w-5 h-5" + /> + +
+ + ))} + +
+ +
+ + {/* Weekly & Monthly Streak */} + +
+ +
+ +

+ + Weekly Habit Streak + +

+ +

+ + 6 / 7 + +

+ +

+ + Keep your momentum going! + +

+ +
+ +
+ +

+ + Monthly Habit Score + +

+ +

+ + 84% + +

+ +

+ + Excellent consistency this month. + +

+ +
+ +
+ + {/* Activity History */} + +
+ +
+ + + +

+ + Recent Activity + +

+ +
+ +
    + +
  • ✅ Solved 8 DSA problems today
  • +
  • ✅ Revised Operating Systems notes
  • +
  • ✅ Completed flashcard review session
  • +
  • 🎤 Finished one mock interview
  • +
  • 📄 Updated resume achievements
  • + +
+ +
+ {/* AI Suggestions */} + +
+ +

+ + AI Study Routine Suggestions + +

+ +
+ + {[ + "Spend 30 minutes revising Dynamic Programming.", + "Complete one mock interview before the weekend.", + "Practice aptitude questions after DSA revision.", + "Review flashcards before ending today's study session.", + "Maintain your study streak by completing at least one task daily.", + ].map((tip, index) => ( + +
+ + 💡 {tip} + +
+ + ))} + +
+ +
+ + {/* Reminder */} + +
+ +
+ + + +

+ + Missed Habit Reminder + +

+ +
+ +

+ + You haven't completed Core Subject Revision today. + +

+ +

+ + Completing it will help maintain your daily habit streak. + +

+ +
+ + {/* Analytics */} + +
+ +

+ + Habit Analytics + +

+ + {[ + ["Daily Consistency", 84], + ["Weekly Goal Completion", 91], + ["Study Routine", 88], + ["Interview Readiness", 89], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Achievement Badges */} + +
+ +

+ + Habit Achievement Badges + +

+ +
+ + {[ + "🔥 7-Day Streak", + "📚 Study Master", + "🎯 Consistency Hero", + "🏆 Interview Ready", + ].map((badge, index) => ( + +
+ + {badge} + +
+ + ))} + +
+ +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Consistency Builds Success 🚀 + +

+ +

+ + Great interview preparation comes from small, + consistent daily habits. Keep your streak alive, + follow AI suggestions, and stay committed to + achieving your career goals. + +

+ +
+ +
+ +
+ + 🔥 + +
+ +

+ + Habit Score + +

+ +

+ + {stats.completion}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AIInterviewPreparationHabitTracker; \ No newline at end of file diff --git a/frontend/src/pages/AIInterviewPreparationMilestoneCalendar/AIInterviewPreparationMilestoneCalendar.jsx b/frontend/src/pages/AIInterviewPreparationMilestoneCalendar/AIInterviewPreparationMilestoneCalendar.jsx new file mode 100644 index 00000000..2556f202 --- /dev/null +++ b/frontend/src/pages/AIInterviewPreparationMilestoneCalendar/AIInterviewPreparationMilestoneCalendar.jsx @@ -0,0 +1,483 @@ +import React, { useState } from "react"; +import { + CalendarDays, + Trophy, + Star, + Flame, + Brain, + Filter, + CheckCircle2, +} from "lucide-react"; + +const AIInterviewPreparationMilestoneCalendar = () => { + + const [stats] = useState({ + milestones: 24, + streak: 32, + readiness: 90, + achievements: 15, + }); + + const [filters, setFilters] = useState({ + month: "August", + category: "All", + type: "All", + }); + + const [milestones] = useState([ + { + date: "Aug 02", + title: "First Mock Interview Completed", + category: "Mock Interview", + }, + { + date: "Aug 04", + title: "Solved 100 Coding Questions", + category: "DSA", + }, + { + date: "Aug 06", + title: "Resume Updated", + category: "Resume", + }, + { + date: "Aug 08", + title: "30-Day Study Streak", + category: "Habit", + }, + ]); + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Interview Preparation Milestone Calendar + +

+ +

+ + View every important milestone from your interview + preparation journey in one interactive calendar. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Milestones + +

+ +

+ + {stats.milestones} + +

+ +
+ +
+ + + +

+ + Best Streak + +

+ +

+ + 🔥 {stats.streak} + +

+ +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + + +

+ + Achievements + +

+ +

+ + {stats.achievements} + +

+ +
+ +
+ + {/* Filters */} + +
+ +
+ + + +

+ + Calendar Filters + +

+ +
+ +
+ + + + + + + +
+ +
+ + {/* Milestone Timeline */} + +
+ +

+ + Milestone Timeline + +

+ +
+ + {milestones.map((item, index) => ( + +
+ +
+ +

+ + {item.date} + +

+ +

+ + {item.title} + +

+ + + + {item.category} + + + +
+ +
+ + ))} + +
+ +
+ + {/* Achievement Calendar */} + +
+ +
+ + + +

+ + Achievement Highlights + +

+ +
+ +
    + +
  • 🏆 First Mock Interview Completed
  • +
  • 💯 Solved 100 Coding Questions
  • +
  • 📄 Resume Successfully Updated
  • +
  • 🔥 Achieved 30-Day Study Streak
  • +
  • ⭐ Completed Core Subject Revision
  • + +
+ +
+ {/* AI Milestone Insights */} + +
+ +

+ + AI Milestone Insights + +

+ +
+ + {[ + "Your study consistency has improved significantly over the past month.", + "Mock interview performance has increased after regular DSA revision.", + "Resume updates positively impacted your interview readiness.", + "Maintaining your study streak is improving long-term retention.", + "You're close to reaching your next preparation milestone.", + ].map((insight, index) => ( + +
+ + 💡 {insight} + +
+ + ))} + +
+ +
+ + {/* Progress Analytics */} + +
+ +

+ + Milestone Analytics + +

+ + {[ + ["Milestones Completed", 90], + ["Preparation Consistency", 88], + ["Skill Improvement", 86], + ["Interview Readiness", 90], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Achievement Summary */} + +
+ +

+ + Achievement Summary + +

+ +

+ + You have achieved {stats.milestones} interview + preparation milestones, maintained a best study streak of{" "} + {stats.streak} days, and reached an interview + readiness score of {stats.readiness}%. + Continue completing new milestones to strengthen your + preparation journey. + +

+ +
+ + {/* Motivation Banner */} + +
+ +
+ +
+ +

+ + Celebrate Every Achievement 🚀 + +

+ +

+ + Every solved question, completed mock interview, + updated resume, and study streak is a milestone + toward your dream job. Keep progressing one step + at a time. + +

+ +
+ +
+ +
+ + 🏆 + +
+ +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AIInterviewPreparationMilestoneCalendar; \ No newline at end of file diff --git a/frontend/src/pages/AILearningPathRecommendations/AILearningPathRecommendations.jsx b/frontend/src/pages/AILearningPathRecommendations/AILearningPathRecommendations.jsx new file mode 100644 index 00000000..66ffdb81 --- /dev/null +++ b/frontend/src/pages/AILearningPathRecommendations/AILearningPathRecommendations.jsx @@ -0,0 +1,514 @@ +import React, { useState } from "react"; +import { + Brain, + Target, + BookOpen, + TrendingUp, + AlertTriangle, + Briefcase, + Clock3, +} from "lucide-react"; + +const AILearningPathRecommendations = () => { + + const [goal, setGoal] = useState("Software Engineer"); + + const [stats] = useState({ + completed: 26, + progress: 81, + readiness: 87, + nextTime: "4 hrs", + }); + + const [completedTopics] = useState([ + { + topic: "Arrays", + progress: 100, + }, + { + topic: "Strings", + progress: 100, + }, + { + topic: "Linked Lists", + progress: 95, + }, + { + topic: "Trees", + progress: 88, + }, + { + topic: "Sorting", + progress: 92, + }, + ]); + + const [weakAreas] = useState([ + "Dynamic Programming", + "Graphs", + "System Design", + "Concurrency", + ]); + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Learning Path Recommendations + +

+ +

+ + Discover your next best learning topic using + AI-powered recommendations based on your + preparation history. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Topics Completed + +

+ +

+ + {stats.completed} + +

+ +
+ +
+ + + +

+ + Progress + +

+ +

+ + {stats.progress}% + +

+ +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + + +

+ + Next Module + +

+ +

+ + {stats.nextTime} + +

+ +
+ +
+ + {/* Career Goal */} + +
+ +
+ + + +

+ + Career Goal + +

+ +
+ + + +
+ + {/* Completed Topics */} + +
+ +

+ + Completed Topics + +

+ + {completedTopics.map((item, index) => ( + +
+ +
+ + {item.topic} + + {item.progress}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Weak Areas */} + +
+ +
+ + + +

+ + Weak Areas + +

+ +
+ +
+ + {weakAreas.map((topic, index) => ( + +
+ + {topic} + +
+ + ))} + +
+ +
+ + {/* Recommendation */} + +
+ +

+ + AI Next Learning Recommendation + +

+ +

+ + Dynamic Programming + +

+ +

+ + Based on your completed topics, assessment scores, + and career goal of becoming a {goal}, + AI recommends studying Dynamic Programming next. + +

+ +

+ + Estimated Completion Time: + 4 Hours + +

+ +
+ {/* Study Time Planner */} + +
+ +

+ + Available Study Time + +

+ +
+ + {["1 Hour", "2 Hours", "4 Hours", "6+ Hours"].map((time, index) => ( + +
+ + + +

{time}

+ +
+ + ))} + +
+ +
+ + {/* Learning Timeline */} + +
+ +

+ + Estimated Learning Timeline + +

+ + {[ + ["Dynamic Programming", "4 hrs"], + ["Graphs", "5 hrs"], + ["System Design Basics", "6 hrs"], + ["Concurrency", "3 hrs"], + ].map(([topic, duration], index) => ( + +
+ + {topic} + + + + {duration} + + + +
+ + ))} + +
+ + {/* AI Recommendations */} + +
+ +

+ + AI Study Recommendations + +

+ +
    + +
  • • Complete Dynamic Programming before moving to Graphs.
  • + +
  • • Spend extra practice time on System Design concepts.
  • + +
  • • Solve at least 10 medium-level DP problems this week.
  • + +
  • • Revise previously completed topics every weekend.
  • + +
  • • Schedule one mock interview after completing each module.
  • + +
+ +
+ + {/* Analytics */} + +
+ +

+ + Learning Progress Analytics + +

+ + {[ + ["Completed Topics", 81], + ["Assessment Performance", 86], + ["Revision Progress", 74], + ["Interview Readiness", 87], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Keep Learning Smarter 🚀 + +

+ +

+ + Personalized learning paths help you focus on the + right topics at the right time. Follow AI + recommendations, strengthen weak areas, and build + confidence for your upcoming interviews. + +

+ +
+ +
+ +
+ + 🎯 + +
+ +

+ + Learning Score + +

+ +

+ + 87% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AILearningPathRecommendations; \ No newline at end of file diff --git a/frontend/src/pages/AIPersonalizedInterviewPreparationTips/AIPersonalizedInterviewPreparationTips.jsx b/frontend/src/pages/AIPersonalizedInterviewPreparationTips/AIPersonalizedInterviewPreparationTips.jsx new file mode 100644 index 00000000..24ada61c --- /dev/null +++ b/frontend/src/pages/AIPersonalizedInterviewPreparationTips/AIPersonalizedInterviewPreparationTips.jsx @@ -0,0 +1,514 @@ +import React, { useState } from "react"; +import { + Brain, + Lightbulb, + TrendingUp, + Target, + Code2, + BookOpen, + Activity, +} from "lucide-react"; + +const AIPersonalizedInterviewPreparationTips = () => { + + const [stats] = useState({ + readiness: 87, + aiTips: 12, + weakTopics: 3, + completedActivities: 26, + }); + + const [tips] = useState([ + "Focus on Dynamic Programming this week.", + "Revise Behavioral Interview Questions.", + "Improve Resume Project Descriptions.", + "Complete one additional Mock Interview.", + "Practice Medium-level Coding Problems.", + ]); + + const [activities] = useState([ + "Solved 15 DSA questions", + "Completed 1 Mock Interview", + "Updated Resume", + "Revised Operating Systems", + "Reviewed Flashcards", + ]); + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Personalized Interview Preparation Tips + +

+ +

+ + Receive AI-generated interview preparation advice + based on your recent activity, strengths, and areas + that need improvement. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + + +

+ + AI Tips + +

+ +

+ + {stats.aiTips} + +

+ +
+ +
+ + + +

+ + Weak Topics + +

+ +

+ + {stats.weakTopics} + +

+ +
+ +
+ + + +

+ + Activities + +

+ +

+ + {stats.completedActivities} + +

+ +
+ +
+ + {/* AI Tips */} + +
+ +
+ + + +

+ + AI Personalized Tips + +

+ +
+ +
+ + {tips.map((tip, index) => ( + +
+ + 💡 {tip} + +
+ + ))} + +
+ +
+ + {/* Recent Activity */} + +
+ +
+ + + +

+ + Recent Activity Summary + +

+ +
+ +
+ + {activities.map((activity, index) => ( + +
+ + ✅ {activity} + +
+ + ))} + +
+ +
+ + {/* Weak Topics */} + +
+ +
+ + + +

+ + Topics Requiring Attention + +

+ +
+ +
+ + {[ + "Dynamic Programming", + "System Design", + "Graphs", + ].map((topic, index) => ( + +
+ + {topic} + +
+ + ))} + +
+ +
+ + {/* Coding Suggestions */} + +
+ +
+ + + +

+ + Coding Practice Suggestions + +

+ +
+ +
    + +
  • • Solve 5 medium-level array problems.
  • +
  • • Practice two Dynamic Programming questions.
  • +
  • • Attempt one graph traversal challenge.
  • +
  • • Revise Binary Search concepts.
  • +
  • • Complete one timed coding assessment.
  • + +
+ +
+ {/* Resume Improvement Tips */} + +
+ +

+ + Resume Improvement Tips + +

+ +
+ + {[ + "Add measurable achievements to project descriptions.", + "Use stronger action verbs in experience sections.", + "Highlight recent technical projects.", + "Keep your resume to one page for campus placements.", + ].map((tip, index) => ( + +
+ + 📄 {tip} + +
+ + ))} + +
+ +
+ + {/* Behavioral Interview Guidance */} + +
+ +

+ + Behavioral Interview Guidance + +

+ +
+ + {[ + "Prepare STAR method answers.", + "Practice introducing yourself confidently.", + "Review teamwork experiences.", + "Prepare examples of problem solving.", + ].map((item, index) => ( + +
+ + 💬 {item} + +
+ + ))} + +
+ +
+ + {/* Mock Interview Recommendations */} + +
+ +

+ + Mock Interview Recommendations + +

+ +
    + +
  • • Schedule one technical mock interview this week.
  • +
  • • Practice explaining your projects in detail.
  • +
  • • Improve communication speed and clarity.
  • +
  • • Review feedback before attempting another mock.
  • +
  • • Focus on confidence during behavioral rounds.
  • + +
+ +
+ + {/* Weekly Insights */} + +
+ +

+ + Weekly Improvement Insights + +

+ + {[ + ["Coding Progress", 84], + ["Core Subjects", 79], + ["Interview Confidence", 81], + ["Resume Quality", 90], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Readiness Summary */} + +
+ +

+ + AI Readiness Summary + +

+ +

+ + Based on your recent preparation activity, you have an + interview readiness score of {stats.readiness}%. + Continue practicing medium-level coding problems, improve + your behavioral interview responses, and strengthen weak + technical topics to maximize your preparation. + +

+ +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Keep Learning, Keep Improving 🚀 + +

+ +

+ + Personalized AI guidance helps you focus on the + right topics at the right time. Stay consistent, + complete your recommendations, and move one step + closer to interview success. + +

+ +
+ +
+ +
+ + 💡 + +
+ +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AIPersonalizedInterviewPreparationTips; diff --git a/frontend/src/pages/AIPersonalizedRevisionChecklist/AIPersonalizedRevisionChecklist.jsx b/frontend/src/pages/AIPersonalizedRevisionChecklist/AIPersonalizedRevisionChecklist.jsx new file mode 100644 index 00000000..ff6130f0 --- /dev/null +++ b/frontend/src/pages/AIPersonalizedRevisionChecklist/AIPersonalizedRevisionChecklist.jsx @@ -0,0 +1,549 @@ +import React, { useState } from "react"; +import { + Brain, + CheckSquare, + BookOpen, + Code2, + FileText, + TrendingUp, + Target, +} from "lucide-react"; + +const AIPersonalizedRevisionChecklist = () => { + + const [stats] = useState({ + readiness: 91, + completed: 18, + remaining: 9, + progress: 67, + }); + + const [tasks, setTasks] = useState([ + { + title: "Revise Binary Search", + category: "DSA", + completed: false, + }, + { + title: "Practice Dynamic Programming", + category: "DSA", + completed: false, + }, + { + title: "Review DBMS Normalization", + category: "Core Subject", + completed: true, + }, + { + title: "Update Resume Projects", + category: "Resume", + completed: false, + }, + ]); + + const toggleTask = (index) => { + const updated = [...tasks]; + updated[index].completed = !updated[index].completed; + setTasks(updated); + }; + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Personalized Revision Checklist + +

+ +

+ + Get a personalized revision checklist generated + automatically from your preparation history, + weak areas, and interview goals. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + + +

+ + Completed + +

+ +

+ + {stats.completed} + +

+ +
+ +
+ + + +

+ + Remaining + +

+ +

+ + {stats.remaining} + +

+ +
+ +
+ + + +

+ + Progress + +

+ +

+ + {stats.progress}% + +

+ +
+ +
+ + {/* Revision Checklist */} + +
+ +

+ + AI Generated Revision Checklist + +

+ +
+ + {tasks.map((task, index) => ( + +
+ +
+ +

+ + {task.title} + +

+ +

+ + {task.category} + +

+ +
+ + toggleTask(index)} + className="w-5 h-5" + /> + +
+ + ))} + +
+ +
+ + {/* Weak DSA Topics */} + +
+ +
+ + + +

+ + Weak DSA Topics + +

+ +
+ +
+ + {[ + "Dynamic Programming", + "Graphs", + "Trie", + "Segment Tree", + ].map((topic, index) => ( + +
+ + {topic} + +
+ + ))} + +
+ +
+ + {/* Core Subjects */} + +
+ +
+ + + +

+ + Core Subject Revision + +

+ +
+ + {[ + "Operating Systems", + "DBMS", + "Computer Networks", + "OOP Concepts", + ].map((subject, index) => ( + +
+ + {subject} + +
+ + ))} + +
+ + {/* Resume Review */} + +
+ +
+ + + +

+ + Resume Review + +

+ +
+ +

+ + ✔ Update project descriptions + +
+ + ✔ Verify technical skills + +
+ + ✔ Add latest achievements + +
+ + ✔ Check ATS-friendly formatting + +

+ +
+ {/* HR & Aptitude Checklist */} + +
+ +

+ + HR & Aptitude Revision + +

+ +
+ + {[ + "Practice HR Introduction", + "Behavioral Questions", + "Quantitative Aptitude", + "Logical Reasoning", + "Verbal Ability", + "Company Research", + ].map((item, index) => ( + +
+ + + + {item} + +
+ + ))} + +
+ +
+ + {/* Flashcard Review */} + +
+ +

+ + Flashcard Review Tasks + +

+ +
+ + {[ + "Operating Systems Flashcards", + "DBMS Flashcards", + "Networking Flashcards", + "OOP Flashcards", + ].map((item, index) => ( + +
+ + {item} + +
+ + ))} + +
+ +
+ + {/* Mock Interview Reminder */} + +
+ +

+ + Mock Interview Reminder + +

+ +

+ + 🎤 Your next mock interview is scheduled for tomorrow. + +

+ +

+ + Complete today's revision checklist before starting + the mock interview session. + +

+ +
+ + {/* Regenerate Checklist */} + +
+ + + +
+ + {/* Revision Analytics */} + +
+ +

+ + Revision Analytics + +

+ + {[ + ["Checklist Completion", 67], + ["DSA Revision", 74], + ["Core Subjects", 81], + ["Interview Readiness", 91], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Motivation Banner */} + +
+ +
+ +
+ +

+ + Revise Smarter, Not Harder 🚀 + +

+ +

+ + Personalized revision helps you focus on your weakest + areas while reinforcing important concepts. Complete + your checklist consistently and let AI guide your + interview preparation journey. + +

+ +
+ +
+ +
+ + ✅ + +
+ +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AIPersonalizedRevisionChecklist; \ No newline at end of file diff --git a/frontend/src/pages/AIPreparationProgressInsights/AIPreparationProgressInsights.jsx b/frontend/src/pages/AIPreparationProgressInsights/AIPreparationProgressInsights.jsx new file mode 100644 index 00000000..657cb7c9 --- /dev/null +++ b/frontend/src/pages/AIPreparationProgressInsights/AIPreparationProgressInsights.jsx @@ -0,0 +1,540 @@ +import React, { useState } from "react"; +import { + Brain, + TrendingUp, + Target, + Award, + AlertTriangle, + BookOpen, + Activity, +} from "lucide-react"; + +const AIPreparationProgressInsights = () => { + + const [stats] = useState({ + readiness: 88, + completed: 24, + strongTopics: 6, + weakTopics: 3, + }); + + const [strongTopics] = useState([ + "Arrays", + "Strings", + "Linked Lists", + "SQL", + "Operating Systems", + "Object-Oriented Programming", + ]); + + const [weakTopics] = useState([ + "Dynamic Programming", + "System Design", + "Graphs", + ]); + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Preparation Progress Insights + +

+ +

+ + View AI-generated insights summarizing your interview + preparation progress, strengths, weaknesses, and + personalized recommendations. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Readiness + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ + + +

+ + Topics Completed + +

+ +

+ + {stats.completed} + +

+ +
+ +
+ + + +

+ + Strong Topics + +

+ +

+ + {stats.strongTopics} + +

+ +
+ +
+ + + +

+ + Need Attention + +

+ +

+ + {stats.weakTopics} + +

+ +
+ +
+ + {/* Weekly Summary */} + +
+ +
+ + + +

+ + Weekly Preparation Summary + +

+ +
+ +
    + +
  • ✅ Solved 38 coding problems
  • + +
  • ✅ Completed 2 mock interviews
  • + +
  • ✅ Revised 5 core CS subjects
  • + +
  • ✅ Improved interview readiness by 6%
  • + +
  • ✅ Maintained a 14-day study streak
  • + +
+ +
+ + {/* Strongest Topics */} + +
+ +
+ + + +

+ + Strongest Topics + +

+ +
+ +
+ + {strongTopics.map((topic, index) => ( + +
+ +

+ + {topic} + +

+ +
+ + ))} + +
+ +
+ + {/* Topics Requiring Attention */} + +
+ +
+ + + +

+ + Topics Requiring Attention + +

+ +
+ +
+ + {weakTopics.map((topic, index) => ( + +
+ + + + {topic} + + + + + + Needs Revision + + + +
+ + ))} + +
+ +
+ + {/* Readiness Overview */} + +
+ +

+ + AI Interview Readiness + +

+ +

+ + {stats.readiness}% + +

+ +

+ + Based on your recent preparation, assessments, + and learning activity, you are progressing well. + Strengthening weak topics can significantly + improve your interview performance. + +

+ +
+ {/* Suggested Next Activities */} + +
+ +

+ + Suggested Next Activities + +

+ +
+ + {[ + "Revise Dynamic Programming", + "Complete one Mock Interview", + "Practice Graph Algorithms", + "Review HR Interview Questions", + "Solve 10 Aptitude Problems", + "Update Resume Projects", + ].map((item, index) => ( + +
+ + + + {item} + +
+ + ))} + +
+ +
+ + {/* Weekly Improvement */} + +
+ +
+ + + +

+ + Weekly Improvement Trends + +

+ +
+ + {[ + ["Week 1", 61], + ["Week 2", 72], + ["Week 3", 80], + ["Week 4", 88], + ].map(([week, value], index) => ( + +
+ +
+ + {week} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* AI Recommendations */} + +
+ +

+ + AI Personalized Recommendations + +

+ +
    + +
  • • Focus on Dynamic Programming before your next mock interview.
  • + +
  • • Spend 30 minutes daily reviewing Graph algorithms.
  • + +
  • • Continue revising Operating Systems to maintain your strength.
  • + +
  • • Schedule another mock interview this weekend.
  • + +
  • • Complete your resume review before applying for placements.
  • + +
+ +
+ + {/* Analytics */} + +
+ +

+ + Progress Analytics + +

+ + {[ + ["Preparation Progress", 88], + ["Concept Retention", 84], + ["Revision Consistency", 91], + ["Interview Confidence", 86], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Performance Summary */} + +
+ +

+ + AI Performance Summary + +

+ +

+ + Your preparation has shown consistent improvement over the + past few weeks. You have built strong foundations in core + interview topics while a few advanced concepts still need + additional revision. Following the AI recommendations will + help increase your interview readiness and confidence. + +

+ +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Keep Improving Every Week 🚀 + +

+ +

+ + Small improvements every day lead to outstanding + interview performance. Trust the insights, focus on + your weak areas, and continue building confidence. + +

+ +
+ +
+ +
+ + 📈 + +
+ +

+ + Readiness Score + +

+ +

+ + {stats.readiness}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AIPreparationProgressInsights; \ No newline at end of file diff --git a/frontend/src/pages/AITopicMasteryProgressBar/AITopicMasteryProgressBar.jsx b/frontend/src/pages/AITopicMasteryProgressBar/AITopicMasteryProgressBar.jsx new file mode 100644 index 00000000..8e50bb29 --- /dev/null +++ b/frontend/src/pages/AITopicMasteryProgressBar/AITopicMasteryProgressBar.jsx @@ -0,0 +1,483 @@ +import React, { useState } from "react"; +import { + Brain, + BookOpen, + TrendingUp, + Target, + CheckCircle2, + BarChart3, + Award, +} from "lucide-react"; + +const AITopicMasteryProgressBar = () => { + + const [stats] = useState({ + mastered: 12, + averageMastery: 82, + confidence: 86, + accuracy: 88, + }); + + const [topics] = useState([ + { + name: "Arrays", + mastery: 96, + confidence: 95, + attempted: 120, + accuracy: 94, + }, + { + name: "Linked Lists", + mastery: 88, + confidence: 86, + attempted: 78, + accuracy: 90, + }, + { + name: "Dynamic Programming", + mastery: 61, + confidence: 58, + attempted: 54, + accuracy: 67, + }, + { + name: "Graphs", + mastery: 72, + confidence: 69, + attempted: 48, + accuracy: 74, + }, + ]); + + return ( + +
+ +
+ + {/* Header */} + +
+ +
+ + + +
+ +
+ +

+ + AI Topic Mastery Progress Bar + +

+ +

+ + Track how well you've mastered each interview topic + using AI-powered mastery scores, confidence analysis, + and personalized progress insights. + +

+ +
+ +
+ + {/* Dashboard */} + +
+ +
+ + + +

+ + Mastered Topics + +

+ +

+ + {stats.mastered} + +

+ +
+ +
+ + + +

+ + Avg. Mastery + +

+ +

+ + {stats.averageMastery}% + +

+ +
+ +
+ + + +

+ + Confidence + +

+ +

+ + {stats.confidence}% + +

+ +
+ +
+ + + +

+ + Accuracy + +

+ +

+ + {stats.accuracy}% + +

+ +
+ +
+ + {/* Topic Mastery */} + +
+ +

+ + Topic Mastery Progress + +

+ + {topics.map((topic, index) => ( + +
+ +
+ +

+ + {topic.name} + +

+ + + + {topic.mastery}% + + + +
+ +
+ +
+ +
+ +
+ +
+ + + +

+ + Questions Attempted + +

+ +

+ + {topic.attempted} + +

+ +
+ +
+ + + +

+ + Confidence Score + +

+ +

+ + {topic.confidence}% + +

+ +
+ +
+ + + +

+ + Accuracy Rate + +

+ +

+ + {topic.accuracy}% + +

+ +
+ +
+ +
+ + ))} + +
+ {/* Revision Status */} + +
+ +

+ + Revision Status + +

+ + {[ + ["Arrays", "Completed"], + ["Linked Lists", "Completed"], + ["Dynamic Programming", "Needs Revision"], + ["Graphs", "In Progress"], + ].map(([topic, status], index) => ( + +
+ + + + {topic} + + + + + + {status} + + + +
+ + ))} + +
+ + {/* AI Recommendations */} + +
+ +

+ + AI Practice Recommendations + +

+ +
    + +
  • • Practice more Dynamic Programming problems.
  • + +
  • • Revise Graph algorithms before your next mock interview.
  • + +
  • • Continue strengthening Arrays and Linked Lists.
  • + +
  • • Attempt advanced coding questions this week.
  • + +
  • • Schedule one revision session for every weak topic.
  • + +
+ +
+ + {/* Analytics */} + +
+ +

+ + Mastery Analytics + +

+ + {[ + ["Topic Mastery", 82], + ["Confidence", 86], + ["Accuracy", 88], + ["Revision Progress", 79], + ].map(([label, value], index) => ( + +
+ +
+ + {label} + + {value}% + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ + {/* Achievement Summary */} + +
+ +

+ + Achievement Summary + +

+ +

+ + You have mastered {stats.mastered} interview + topics with an average mastery score of{" "} + {stats.averageMastery}%. Continue focusing on + weaker areas to increase your confidence and overall interview + readiness. + +

+ +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Master Every Topic 🚀 + +

+ +

+ + Every revision session strengthens your understanding. + Keep practicing consistently, improve your weak areas, + and let AI guide you toward complete interview mastery. + +

+ +
+ +
+ +
+ + 🏆 + +
+ +

+ + Mastery Score + +

+ +

+ + {stats.averageMastery}% + +

+ +
+ +
+ +
+ +
+ +
+ + ); +}; + +export default AITopicMasteryProgressBar; \ No newline at end of file diff --git a/frontend/src/pages/AchievementCertificates/AchievementCertificates.jsx b/frontend/src/pages/AchievementCertificates/AchievementCertificates.jsx new file mode 100644 index 00000000..a0b031c4 --- /dev/null +++ b/frontend/src/pages/AchievementCertificates/AchievementCertificates.jsx @@ -0,0 +1,397 @@ +import React, { useState } from "react"; +import { + Award, + BadgeCheck, + Trophy, + Calendar, + Target, + Star, + QrCode, + Download, + Share2, +} from "lucide-react"; + +const AchievementCertificates = () => { + const [certificates] = useState([ + { + id: "PP-1001", + title: "100 Interview Questions Completed", + date: "10 Aug 2026", + unlocked: true, + progress: 100, + color: "from-violet-500 to-purple-600", + }, + { + id: "PP-1002", + title: "Completed DSA Sheet", + date: "15 Aug 2026", + unlocked: true, + progress: 100, + color: "from-green-500 to-emerald-600", + }, + { + id: "PP-1003", + title: "30-Day Study Streak", + date: "Locked", + unlocked: false, + progress: 72, + color: "from-orange-500 to-red-500", + }, + { + id: "PP-1004", + title: "10 Mock Interviews", + date: "Locked", + unlocked: false, + progress: 50, + color: "from-blue-500 to-cyan-500", + }, + { + id: "PP-1005", + title: "90% Assessment Score", + date: "20 Aug 2026", + unlocked: true, + progress: 100, + color: "from-pink-500 to-rose-500", + }, + ]); + + return ( +
+ +
+ + {/* Header */} + +
+ +
+ +
+ + + +
+ +
+ +

+ Interview Preparation Certificates +

+ +

+ Earn achievement certificates as you complete + interview preparation milestones. +

+ +
+ +
+ + + +
+ {/* Statistics */} + +
+ +
+ + + +

+ Certificates Earned +

+ +

+ 3 +

+ +
+ +
+ + + +

+ Questions Solved +

+ +

+ 124 +

+ +
+ +
+ + + +

+ Current Streak +

+ +

+ 21 Days +

+ +
+ +
+ + + +

+ Mock Interviews +

+ +

+ 8 +

+ +
+ +
+ + {/* Progress */} + +
+ +

+ Achievement Progress +

+ +
+ + {certificates.map((item) => ( + +
+ +
+ + + {item.title} + + + + {item.progress}% + + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ +
+ {/* Certificate Gallery */} + +
+ +

+ Achievement Certificates +

+ +
+ + {certificates.map((certificate) => ( + +
+ + {/* Top Banner */} + +
+ + + +

+ {certificate.title} +

+ +

+ Certificate of Achievement +

+ +
+ + {/* Body */} + +
+ +
+ + + + + {certificate.date} + + +
+ +
+ + + Certificate ID + + +

+ {certificate.id} +

+ +
+ + {/* QR Placeholder */} + +
+ +
+ + + +
+ +
+ + {/* Status */} + +
+ + {certificate.unlocked ? ( + + + Unlocked + + + ) : ( + + + Locked + + + )} + +
+ + {/* Buttons */} + +
+ + + + + +
+ +
+ +
+ + ))} + +
+ +
+ {/* Motivation Section */} + +
+ +
+ +
+ +

+ Keep Unlocking New Achievements 🚀 +

+ +

+ Every interview question solved, every mock interview completed, + and every study streak brings you one step closer to your dream + job. Continue preparing consistently to unlock more certificates + and showcase your achievements. +

+ +
+ +
+ +
+ 🏆 +
+ +

+ Next Milestone +

+ +

+ Complete 10 Mock Interviews +

+ +
+ +
+ +
+ +
+ +
+ ); +}; + +export default AchievementCertificates; \ No newline at end of file diff --git a/frontend/src/pages/AchievementShowcase/AchievementShowcase.jsx b/frontend/src/pages/AchievementShowcase/AchievementShowcase.jsx new file mode 100644 index 00000000..305f7db6 --- /dev/null +++ b/frontend/src/pages/AchievementShowcase/AchievementShowcase.jsx @@ -0,0 +1,649 @@ +import React, { useState } from "react"; +import { + Trophy, + Medal, + Star, + Award, + User, + Share2, +} from "lucide-react"; + +const AchievementShowcase = () => { + + const [user] = useState({ + name: "John Doe", + level: "Interview Ready", + badges: 18, + points: 2840, + rank: "#127", + }); + + return ( +
+ +
+ + {/* Header */} + +
+ +
+ +
+ + + +
+ +
+ +

+ Preparation Achievement Showcase +

+ +

+ + Display all the badges and achievements + earned throughout your interview preparation + journey. + +

+ +
+ +
+ + + +
+ + {/* Profile Card */} + +
+ +
+ +
+ +
+ + + +
+ +
+ +

+ + {user.name} + +

+ +

+ + {user.level} + +

+ +
+ +
+ +
+ +
+ + + +

+ + {user.badges} + +

+ +

+ Badges +

+ +
+ +
+ + + +

+ + {user.points} + +

+ +

+ Points +

+ +
+ +
+ + + +

+ + {user.rank} + +

+ +

+ Global Rank +

+ +
+ +
+ +
+ +
+ {/* Earned Badges */} + +
+ +

+ Earned Achievement Badges +

+ +
+ + {[ + { + title: "DSA Master", + icon: "💻", + description: "Solved 300+ DSA questions.", + rarity: "Legendary", + color: "bg-yellow-100 text-yellow-700", + }, + { + title: "Mock Interview Pro", + icon: "🎤", + description: "Completed 25 mock interviews.", + rarity: "Epic", + color: "bg-violet-100 text-violet-700", + }, + { + title: "Resume Expert", + icon: "📄", + description: "Created an ATS-friendly resume.", + rarity: "Rare", + color: "bg-green-100 text-green-700", + }, + { + title: "Study Streak", + icon: "🔥", + description: "Maintained a 30-day streak.", + rarity: "Epic", + color: "bg-red-100 text-red-700", + }, + { + title: "Flashcard Master", + icon: "📚", + description: "Reviewed 1000+ flashcards.", + rarity: "Rare", + color: "bg-blue-100 text-blue-700", + }, + { + title: "Community Contributor", + icon: "🌍", + description: "Contributed to open source projects.", + rarity: "Legendary", + color: "bg-indigo-100 text-indigo-700", + }, + ].map((badge, index) => ( + +
+ +
+ + {badge.icon} + +
+ +

+ + {badge.title} + +

+ +

+ + {badge.description} + +

+ +
+ + + + {badge.rarity} + + + +
+ +
+ + ))} + +
+ +
+ + {/* Badge Categories */} + +
+ +

+ + Badge Categories + +

+ +
+ + {[ + { + name: "DSA", + count: 5, + icon: "💻", + }, + { + name: "Mock Interviews", + count: 4, + icon: "🎤", + }, + { + name: "Resume", + count: 2, + icon: "📄", + }, + { + name: "Study", + count: 7, + icon: "📚", + }, + ].map((item, index) => ( + +
+ +
+ + {item.icon} + +
+ +

+ + {item.name} + +

+ +

+ + {item.count} Badges + +

+ +
+ + ))} + +
+ +
+ {/* Locked Badges */} + +
+ +

+ Locked Badges +

+ +
+ + {[ + { + title: "Interview Champion", + progress: 80, + requirement: "Complete 50 Mock Interviews", + }, + { + title: "Algorithm Wizard", + progress: 62, + requirement: "Solve 500 DSA Questions", + }, + { + title: "Open Source Hero", + progress: 45, + requirement: "Merge 20 Open Source PRs", + }, + ].map((badge, index) => ( + +
+ +
+ 🔒 +
+ +

+ {badge.title} +

+ +

+ {badge.requirement} +

+ +
+ +
+ + + Progress + + + + {badge.progress}% + + +
+ +
+ +
+ +
+ +
+ +
+ + ))} + +
+ +
+ + {/* Badge Statistics */} + +
+ +
+ +

+ Total Badges +

+ +

+ 18 +

+ +
+ +
+ +

+ Legendary +

+ +

+ 4 +

+ +
+ +
+ +

+ Epic +

+ +

+ 7 +

+ +
+ +
+ +

+ Rare +

+ +

+ 7 +

+ +
+ +
+ + {/* Achievement Progress */} + +
+ +

+ Progress Towards Next Achievements +

+ +
+ + {[ + { + title: "Mock Interviews", + progress: 80, + }, + { + title: "DSA Sheet Completion", + progress: 74, + }, + { + title: "Study Streak", + progress: 92, + }, + { + title: "Community Contributions", + progress: 48, + }, + ].map((item, index) => ( + +
+ +
+ + + {item.title} + + + + {item.progress}% + + +
+ +
+ +
+ +
+ +
+ + ))} + +
+ +
+ {/* Share Achievements */} + +
+ +
+ + + +

+ Share Your Achievements +

+ +
+ +

+ + Share your preparation journey and achievement badges + with your friends, mentors, and professional network. + +

+ +
+ + + + + +
+ +
+ + {/* AI Achievement Summary */} + +
+ +

+ AI Achievement Summary +

+ +

+ + Congratulations! You have consistently progressed + through multiple interview preparation modules. + + Your strongest areas include DSA practice, + study consistency, resume preparation, + and mock interviews. + + Continue contributing to open source, + solving advanced problems, and maintaining + your study streak to unlock legendary badges. + +

+ +
+ + {/* Motivation */} + +
+ +
+ +
+ +

+ + Every Badge Tells Your Story 🏆 + +

+ +

+ + Each achievement reflects your dedication, + consistency, and continuous learning. + + Keep practicing, keep improving, + and unlock every badge on your + interview preparation journey. + +

+ +
+ +
+ +
+ + 🏅 + +
+ +

+ + Achievement Score + +

+ +

+ + 97% + +

+ +
+ +
+ +
+ +
+ +
+ ); +}; + +export default AchievementShowcase; \ No newline at end of file diff --git a/frontend/src/pages/Auth/Login.jsx b/frontend/src/pages/Auth/Login.jsx index 3489d1b3..9217869d 100644 --- a/frontend/src/pages/Auth/Login.jsx +++ b/frontend/src/pages/Auth/Login.jsx @@ -118,7 +118,7 @@ const Login = ({ setCurrentPage, onLoginSuccess }) => { }} label="Email Address" placeholder="your@email.com" - type="text" + type="email" autoFocus />
diff --git a/frontend/src/pages/Auth/SignUp.jsx b/frontend/src/pages/Auth/SignUp.jsx index 3f67a09a..b4e054d3 100644 --- a/frontend/src/pages/Auth/SignUp.jsx +++ b/frontend/src/pages/Auth/SignUp.jsx @@ -52,7 +52,7 @@ const SignUp = ({ setCurrentPage }) => { if (!fullName) { setError("Please enter your full name"); return; } if(containsNumber(fullName) || !containsAlphanumeric(fullName)) { setError("Full name should not contain numbers or special characters"); return; } - if (!validateEmail(email) && !email.endsWith(".com") && !email.includes("@")) { setError("Please enter a valid email address"); return; } + if (!validateEmail(email) || !email.endsWith(".com") || !email.includes("@")) { setError("Please enter a valid email address"); return; } if (!password || password.length < 8) { setError("Password must be at least 8 characters long."); return; } if (!/[A-Z]/.test(password)) { setError("Password must contain at least one uppercase letter."); return; } if (!/[a-z]/.test(password)) { setError("Password must contain at least one lowercase letter."); return; } diff --git a/frontend/src/pages/Auth/verifyEmail.jsx b/frontend/src/pages/Auth/verifyEmail.jsx index 8045e36d..513bb142 100644 --- a/frontend/src/pages/Auth/verifyEmail.jsx +++ b/frontend/src/pages/Auth/verifyEmail.jsx @@ -43,7 +43,7 @@ const VerifyEmail = () => { }; verify(); - }, []); + }, [navigate, searchParams]); return (
diff --git a/frontend/src/pages/BehavioralCoach/BehavioralCoach.jsx b/frontend/src/pages/BehavioralCoach/BehavioralCoach.jsx new file mode 100644 index 00000000..7bc2dfc6 --- /dev/null +++ b/frontend/src/pages/BehavioralCoach/BehavioralCoach.jsx @@ -0,0 +1,356 @@ +import React, { useState } from "react"; +import { + Brain, + MessageSquare, + Target, + CheckCircle2, + AlertTriangle, + Sparkles, +} from "lucide-react"; + +import axiosInstance from "../../utils/axiosinstance"; +import { API_PATHS } from "../../utils/apiPaths"; + +const BehavioralCoach = () => { + const questions = [ + "Tell me about yourself.", + "Describe a time you handled conflict in your team.", + "Tell me about a difficult project you completed.", + "Describe a situation where you showed leadership.", + "Tell me about a time you failed and what you learned.", + "Describe a time when you worked under pressure.", + "Tell me about a time you solved a difficult problem.", + "Describe a situation where you disagreed with your manager.", + ]; + + const [currentQuestion, setCurrentQuestion] = useState(questions[0]); + const [answer, setAnswer] = useState(""); + const [loading, setLoading] = useState(false); + const [analysis, setAnalysis] = useState(null); + + const analyzeAnswer = async () => { + if (!answer.trim()) return; + + setLoading(true); + + try { + const res = await axiosInstance.post( + API_PATHS.BEHAVIORAL.ANALYZE, + { + question: currentQuestion, + answer, + } + ); + + setAnalysis(res.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const nextQuestion = () => { + const random = + questions[Math.floor(Math.random() * questions.length)]; + + setCurrentQuestion(random); + setAnswer(""); + setAnalysis(null); + }; + + return ( +
+ +
+ +
+ +
+ +
+ +
+

+ AI Behavioral Interview Coach +

+ +

+ Practice behavioral interviews using the STAR framework. +

+
+ +
+ +
+ +
+ + + +

+ Interview Question +

+ +
+ +
+ +

+ {currentQuestion} +

+ +
+ +