Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions backend/controllers/flashcardController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -273,4 +276,4 @@ module.exports = {
deleteFlashcard,
getFlashcardStats,
calculateSM2,
};
};
98 changes: 65 additions & 33 deletions backend/controllers/jobController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]);

Comment on lines +7 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚑ Quick win

Use one validated effective default country.

If ADZUNA_COUNTRY is invalid or contains whitespace, getJobs returns HTTP 400 even when the client does not send country. refreshJobCache instead falls back to "in", so the API and refresh paths use different configuration behavior.

Trim and validate the configured value at initialization. Use the same validated fallback in both paths.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/controllers/jobController.js` around lines 7 - 15, Normalize
ADZUNA_COUNTRY at initialization by trimming it, validating it against
SUPPORTED_COUNTRIES, and falling back to "in" when invalid or blank. Update
getJobs and refreshJobCache to use this single validated effective country so
both paths share identical configuration behavior.

const isAdzunaConfigured = () => Boolean(ADZUNA_APP_ID && ADZUNA_API_KEY);

// Bound the set of cache keys: role/country are client-controlled, so we trim,
Expand All @@ -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()) {
Expand All @@ -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(", ")}`,
});
}
Comment on lines +94 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

Update the invalid-country unit test.

backend/tests/jobCache.boundedKeys.unit.test.js expects country=USA!! to fall back to backend|in. This handler now returns HTTP 400 before it creates a cache key. The existing test will fail.

Change the test to assert the HTTP 400 response and that no cache write occurs.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/controllers/jobController.js` around lines 94 - 100, Update the
invalid-country test in jobCache.boundedKeys.unit.test.js to expect an HTTP 400
response for country=USA!!, and verify that no cache write is performed instead
of expecting a backend|in cache key.


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}`;
Comment on lines +108 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ—„οΈ Data Integrity & Integration | 🟠 Major | ⚑ Quick win

Restore role normalization in both cache-write paths.

Both paths bypass normalizeRole. Raw roles can create unbounded cache keys and bypass the existing bounded-key contract.

  • backend/controllers/jobController.js#L108-L109: normalize the requested or session role, then use the default role when normalization fails.
  • backend/controllers/jobController.js#L137-L141: normalize each stored role and skip values that fail normalization before calling Adzuna.
πŸ“ Affects 1 file
  • backend/controllers/jobController.js#L108-L109 (this comment)
  • backend/controllers/jobController.js#L137-L141
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/controllers/jobController.js` around lines 108 - 109, Update
backend/controllers/jobController.js lines 108-109 to pass the requested or
session role through normalizeRole and fall back to the default role when
normalization fails; retain the normalized role for cacheKey generation. At
lines 137-141, normalize every stored role with normalizeRole and skip entries
that fail normalization before invoking Adzuna.


const cached = await JobCache.findOne({ cacheKey });
if (cached && Date.now() - cached.fetchedAt.getTime() < CACHE_TTL_MS) {
Expand All @@ -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) {
Expand All @@ -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);
}
};
};
23 changes: 12 additions & 11 deletions frontend/src/utils/apiPaths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
Loading