diff --git a/backend/controllers/flashcardController.js b/backend/controllers/flashcardController.js index 90cdd3fc..30cc9f27 100644 --- a/backend/controllers/flashcardController.js +++ b/backend/controllers/flashcardController.js @@ -242,7 +242,10 @@ const getFlashcardStats = async (req, res) => { interval: { $gte: 21 }, }); - const startOfDay = new Date(now.setHours(0, 0, 0, 0)); + // Fix: Clone 'now' before setting hours to avoid in-place mutation of 'now' + const startOfDay = new Date(now); + startOfDay.setHours(0, 0, 0, 0); + const reviewedToday = await Flashcard.countDocuments({ userId, lastReviewedAt: { $gte: startOfDay }, @@ -273,4 +276,4 @@ module.exports = { deleteFlashcard, getFlashcardStats, calculateSM2, -}; +}; \ No newline at end of file diff --git a/backend/controllers/jobController.js b/backend/controllers/jobController.js index 46732fc4..52fffdc4 100644 --- a/backend/controllers/jobController.js +++ b/backend/controllers/jobController.js @@ -4,11 +4,15 @@ const JobCache = require("../models/JobCache"); const ADZUNA_APP_ID = process.env.ADZUNA_APP_ID; const ADZUNA_API_KEY = process.env.ADZUNA_API_KEY; -const ADZUNA_COUNTRY = process.env.ADZUNA_COUNTRY || "in"; +const ADZUNA_COUNTRY = (process.env.ADZUNA_COUNTRY || "in").toLowerCase(); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; -// The Jobs feature is optional: without Adzuna credentials it stays dormant -// instead of crashing the server or spamming failed API calls. +// Supported Adzuna country codes +const SUPPORTED_COUNTRIES = new Set([ + "gb", "us", "in", "ca", "au", "de", "fr", "br", "za", + "at", "be", "ch", "es", "it", "nl", "nz", "pl", "ru", "sg" +]); + const isAdzunaConfigured = () => Boolean(ADZUNA_APP_ID && ADZUNA_API_KEY); // Bound the set of cache keys: role/country are client-controlled, so we trim, @@ -32,25 +36,49 @@ async function fetchFromAdzuna(role, country = ADZUNA_COUNTRY) { const url = `https://api.adzuna.com/v1/api/jobs/${country}/search/1`; const { data } = await axios.get(url, { params: { - app_id: ADZUNA_APP_ID, - app_key: ADZUNA_API_KEY, - what: role, + app_id: ADZUNA_APP_ID, + app_key: ADZUNA_API_KEY, + what: role, results_per_page: 10, }, }); return (data.results || []).map((j) => ({ - id: j.id, - title: j.title, - company: j.company?.display_name || "Unknown", - location: j.location?.display_name || "Remote", - salary_min: j.salary_min || null, - salary_max: j.salary_max ?? null, - description: j.description ?? "", + id: j.id, + title: j.title, + company: j.company?.display_name || "Unknown", + location: j.location?.display_name || "Remote", + salary_min: j.salary_min || null, + salary_max: j.salary_max ?? null, + description: j.description ?? "", redirect_url: j.redirect_url, - created: j.created, + created: j.created, })); } +/** + * Handles concurrent upserts safely by catching E11000 duplicate key errors + * and falling back to a standard update. + */ +async function upsertJobCache(cacheKey, jobs) { + const updateData = { jobs, fetchedAt: new Date() }; + try { + return await JobCache.findOneAndUpdate( + { cacheKey }, + updateData, + { upsert: true, new: true } + ); + } catch (err) { + if (err.code === 11000) { + return await JobCache.findOneAndUpdate( + { cacheKey }, + updateData, + { new: true } + ); + } + throw err; + } +} + exports.getJobs = async (req, res) => { try { if (!isAdzunaConfigured()) { @@ -63,15 +91,22 @@ exports.getJobs = async (req, res) => { }); } + // Validate country parameter if explicitly provided + let country = req.query.country ? String(req.query.country).toLowerCase() : ADZUNA_COUNTRY; + if (!SUPPORTED_COUNTRIES.has(country)) { + return res.status(400).json({ + message: `Invalid or unsupported country code: "${req.query.country}". Supported codes are: ${Array.from(SUPPORTED_COUNTRIES).join(", ")}`, + }); + } + const userId = req.user._id; const latestSession = await Session.findOne({ user: userId }) .sort({ createdAt: -1 }) .select("role"); - const role = normalizeRole(req.query.role) || normalizeRole(latestSession?.role) || "software engineer"; - const country = normalizeCountry(req.query.country); - const cacheKey = `${role}|${country}`; + const role = req.query.role || latestSession?.role || "software engineer"; + const cacheKey = `${role.toLowerCase()}|${country}`; const cached = await JobCache.findOne({ cacheKey }); if (cached && Date.now() - cached.fetchedAt.getTime() < CACHE_TTL_MS) { @@ -80,11 +115,7 @@ exports.getJobs = async (req, res) => { const jobs = await fetchFromAdzuna(role, country); - await JobCache.findOneAndUpdate( - { cacheKey }, - { jobs, fetchedAt: new Date() }, - { upsert: true, new: true } - ); + await upsertJobCache(cacheKey, jobs); return res.json({ jobs, role, source: "api" }); } catch (err) { @@ -95,24 +126,25 @@ exports.getJobs = async (req, res) => { exports.refreshJobCache = async () => { if (!isAdzunaConfigured()) return; + + const targetCountry = SUPPORTED_COUNTRIES.has(ADZUNA_COUNTRY) ? ADZUNA_COUNTRY : "in"; + try { const roles = await Session.distinct("role", { createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, }); for (const role of roles) { - const normalizedRole = normalizeRole(role); - if (!normalizedRole) continue; - const cacheKey = `${normalizedRole}|${ADZUNA_COUNTRY}`; - const jobs = await fetchFromAdzuna(normalizedRole); - await JobCache.findOneAndUpdate( - { cacheKey }, - { jobs, fetchedAt: new Date() }, - { upsert: true, new: true } - ); - + try { + const cacheKey = `${role.toLowerCase()}|${targetCountry}`; + const jobs = await fetchFromAdzuna(role, targetCountry); + await upsertJobCache(cacheKey, jobs); + console.log(`[JobCron] Refreshed cache for: ${role}`); + } catch (roleErr) { + console.error(`[JobCron] Failed to refresh role "${role}":`, roleErr.message); + } } } catch (err) { console.error("[JobCron] Refresh failed:", err.message); } -}; +}; \ No newline at end of file diff --git a/frontend/src/utils/apiPaths.js b/frontend/src/utils/apiPaths.js index 23f46dd6..a10ba815 100644 --- a/frontend/src/utils/apiPaths.js +++ b/frontend/src/utils/apiPaths.js @@ -4,17 +4,18 @@ export const BASE_URL = export const API_PATHS = { AUTH: { - REGISTER: "/api/auth/register", - LOGIN: "/api/auth/login", - VERIFY_EMAIL: "/api/auth/verify-email", - RESEND_VERIFICATION: "/api/auth/resend-verification", - GET_PROFILE: "/api/auth/profile", - - UPDATE_PROFILE: "/api/auth/profile", - CHANGE_PASSWORD: "/api/auth/change-password", - DELETE_ACCOUNT: "/api/auth/delete-account", - LOGOUT: "/api/auth/logout", -}, + REGISTER: "/api/auth/register", + LOGIN: "/api/auth/login", + VERIFY_EMAIL: "/api/auth/verify-email", + RESEND_VERIFICATION: "/api/auth/resend-verification", + FORGOT_PASSWORD: "/api/auth/forgot-password", + RESET_PASSWORD: "/api/auth/reset-password", + GET_PROFILE: "/api/auth/profile", + UPDATE_PROFILE: "/api/auth/profile", + CHANGE_PASSWORD: "/api/auth/change-password", + DELETE_ACCOUNT: "/api/auth/delete-account", + LOGOUT: "/api/auth/logout", + }, IMAGE: { UPLOAD_IMAGE: "/api/auth/upload-image", // Upload profile picture },