From 8d4e77284283dd89d97d1b57a37e0d1cd59405b8 Mon Sep 17 00:00:00 2001 From: GitGuru-sudo Date: Fri, 3 Jul 2026 12:04:00 +0530 Subject: [PATCH 1/2] feat: add Jobs for You section with Adzuna API integration --- backend/.env.example | 7 + backend/config/validateEnv.js | 2 + backend/controllers/jobController.js | 85 +++++++++ backend/models/JobCache.js | 21 +++ backend/routes/jobRoutes.js | 8 + backend/server.js | 7 + frontend/src/App.jsx | 11 ++ frontend/src/components/Layouts/Sidebar.jsx | 14 ++ frontend/src/pages/Jobs/JobsForYou.jsx | 184 ++++++++++++++++++++ frontend/src/utils/apiPaths.js | 5 +- 10 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 backend/controllers/jobController.js create mode 100644 backend/models/JobCache.js create mode 100644 backend/routes/jobRoutes.js create mode 100644 frontend/src/pages/Jobs/JobsForYou.jsx diff --git a/backend/.env.example b/backend/.env.example index 473f977..c4fd6a8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,3 +54,10 @@ FRONTEND_URL=http://localhost:5173 # NODE_ENV=production (for production deployments) # NODE_ENV=development (default for local) NODE_ENV=development + +# Adzuna Job API (free tier — register at https://developer.adzuna.com) +# No credit card required. Get App ID + API Key after signing up. +ADZUNA_APP_ID=your_adzuna_app_id +ADZUNA_API_KEY=your_adzuna_api_key +# Country code for job listings: "in"=India, "us"=USA, "gb"=UK, "au"=Australia etc. +ADZUNA_COUNTRY=in diff --git a/backend/config/validateEnv.js b/backend/config/validateEnv.js index 418ed8d..afeae5d 100644 --- a/backend/config/validateEnv.js +++ b/backend/config/validateEnv.js @@ -2,6 +2,8 @@ const requiredEnvVars = Object.freeze([ "MONGO_URI", "JWT_SECRET", "GEMINI_API_KEY", + "ADZUNA_APP_ID", + "ADZUNA_API_KEY", ]); const validateEnv = () => { diff --git a/backend/controllers/jobController.js b/backend/controllers/jobController.js new file mode 100644 index 0000000..c19a55d --- /dev/null +++ b/backend/controllers/jobController.js @@ -0,0 +1,85 @@ +const axios = require("axios"); +const Session = require("../models/Session"); +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 CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +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, + results_per_page: 10, + content_type: "application/json", + }, + }); + 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 || "", + redirect_url: j.redirect_url, + created: j.created, + })); +} + +exports.getJobs = async (req, res) => { + try { + const userId = req.user._id; + + const latestSession = await Session.findOne({ user: userId }) + .sort({ createdAt: -1 }) + .select("role"); + + const role = latestSession?.role || req.query.role || "software engineer"; + const country = req.query.country || ADZUNA_COUNTRY; + const cacheKey = `${role.toLowerCase()}|${country}`; + + const cached = await JobCache.findOne({ cacheKey }); + if (cached && Date.now() - cached.fetchedAt.getTime() < CACHE_TTL_MS) { + return res.json({ jobs: cached.jobs, role, source: "cache" }); + } + + const jobs = await fetchFromAdzuna(role, country); + + await JobCache.findOneAndUpdate( + { cacheKey }, + { jobs, fetchedAt: new Date() }, + { upsert: true, new: true } + ); + + return res.json({ jobs, role, source: "api" }); + } catch (err) { + console.error("[Jobs] getJobs error:", err.message); + res.status(500).json({ message: "Failed to fetch jobs", error: err.message }); + } +}; + +exports.refreshJobCache = async () => { + try { + const roles = await Session.distinct("role", { + createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + }); + + for (const role of roles) { + const cacheKey = `${role.toLowerCase()}|${ADZUNA_COUNTRY}`; + const jobs = await fetchFromAdzuna(role); + await JobCache.findOneAndUpdate( + { cacheKey }, + { 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/models/JobCache.js b/backend/models/JobCache.js new file mode 100644 index 0000000..6c04b24 --- /dev/null +++ b/backend/models/JobCache.js @@ -0,0 +1,21 @@ +const mongoose = require("mongoose"); + +const JobCacheSchema = new mongoose.Schema({ + cacheKey: { type: String, required: true, unique: true }, + jobs: [ + { + id: String, + title: String, + company: String, + location: String, + salary_min: Number, + salary_max: Number, + description: String, + redirect_url: String, + created: String, + }, + ], + fetchedAt: { type: Date, default: Date.now }, +}); + +module.exports = mongoose.model("JobCache", JobCacheSchema); diff --git a/backend/routes/jobRoutes.js b/backend/routes/jobRoutes.js new file mode 100644 index 0000000..b2c1f52 --- /dev/null +++ b/backend/routes/jobRoutes.js @@ -0,0 +1,8 @@ +const express = require("express"); +const router = express.Router(); +const { protect } = require("../middlewares/authMiddleware"); +const { getJobs } = require("../controllers/jobController"); + +router.get("/", protect, getJobs); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 2c287c5..18a5dc6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ const questionRoutes = require("./routes/questionRoutes"); const aiRoutes = require("./routes/aiRoutes"); const resumeRoutes = require("./routes/resumeRoutes"); const aptitudeQuestionsRoutes = require("./routes/AptitudeQuestions.js"); +const jobRoutes = require("./routes/jobRoutes"); const { generalLimiter, aiLimiter } = require("./middlewares/rateLimiter"); const { generalHeaders, sensitiveRouteHeaders } = require("./middlewares/securityHeaders"); // Remove ES Module import for cors. Use CommonJS require below. @@ -129,6 +130,7 @@ app.use( ); app.use("/api/books", generalLimiter, booksRoutes); +app.use("/api/jobs", generalLimiter, jobRoutes); //Serve uploads folder app.use("/uploads", express.static(path.join(__dirname, "uploads"), {})); @@ -140,6 +142,11 @@ app.get("/api/test", (req, res) => { // Remove duplicate CORS middleware (already set above) +// Daily job cache refresh — warm on boot, then every 24 hours +const { refreshJobCache } = require("./controllers/jobController"); +refreshJobCache(); +setInterval(refreshJobCache, 24 * 60 * 60 * 1000); + // Start Server const PORT = process.env.PORT || 5000; const server = app.listen(PORT, "0.0.0.0", () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1896b3c..6be6be4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,6 +34,7 @@ import RepositoryHive from "./pages/OpenSource/RepositoryHive"; import OSSBlog from "./pages/OpenSource/OSSBlog"; import OpenSourceEvents from "./pages/OpenSource/OpenSourceEvents"; import NotesBooks from "./pages/NotesBooks/NotesBooks"; +import JobsForYou from "./pages/Jobs/JobsForYou"; import HelpSupport from "./pages/Support/HelpSupport"; import Settings from "./pages/Settings/Settings"; import NotFound from "./pages/NotFound"; @@ -314,6 +315,16 @@ const App = () => { } /> + + + + + + } + /> { }, ], }, + { + id: "jobs", + title: "Jobs", + isHeader: true, + items: [ + { + id: "jobs-for-you", + title: "Jobs for You", + path: "/jobs", + icon: BriefcaseBusiness, + }, + ], + }, { id: "project", title: "Project", diff --git a/frontend/src/pages/Jobs/JobsForYou.jsx b/frontend/src/pages/Jobs/JobsForYou.jsx new file mode 100644 index 0000000..ca8621d --- /dev/null +++ b/frontend/src/pages/Jobs/JobsForYou.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState, useContext } from "react"; +import { Briefcase, MapPin, DollarSign, ExternalLink, RefreshCw } from "lucide-react"; +import { UserContext } from "../../context/userContext"; +import axiosInstance from "../../utils/axiosinstance"; +import { API_PATHS } from "../../utils/apiPaths"; +import toast from "react-hot-toast"; + +const COUNTRY_OPTIONS = [ + { code: "in", label: "India" }, + { code: "us", label: "United States" }, + { code: "gb", label: "United Kingdom" }, + { code: "au", label: "Australia" }, + { code: "ca", label: "Canada" }, +]; + +const JobCard = ({ job }) => ( +
+
+
+
+

{job.title}

+

{job.company}

+
+ + New + +
+ +
+ + {job.location} + + {(job.salary_min || job.salary_max) && ( + + + {job.salary_min && job.salary_max + ? `${Math.round(job.salary_min / 1000)}k – ${Math.round(job.salary_max / 1000)}k` + : job.salary_min + ? `From ${Math.round(job.salary_min / 1000)}k` + : `Up to ${Math.round(job.salary_max / 1000)}k`} + + )} +
+ +

+ {job.description} +

+
+ + + Apply Now + +
+); + +const JobsForYou = () => { + const { user } = useContext(UserContext); + const [jobs, setJobs] = useState([]); + const [role, setRole] = useState(""); + const [country, setCountry] = useState("in"); + const [loading, setLoading] = useState(true); + const [customRole, setCustomRole] = useState(""); + + const fetchJobs = async (selectedCountry, overrideRole = "") => { + setLoading(true); + try { + const params = { country: selectedCountry }; + if (overrideRole) params.role = overrideRole; + const res = await axiosInstance.get(API_PATHS.JOBS.GET, { params }); + setJobs(res.data.jobs || []); + setRole(res.data.role || ""); + } catch (err) { + toast.error("Failed to load job listings. Please try again."); + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchJobs(country); + }, []); + + const handleCountryChange = (e) => { + setCountry(e.target.value); + fetchJobs(e.target.value, customRole); + }; + + const handleSearch = (e) => { + e.preventDefault(); + fetchJobs(country, customRole); + }; + + return ( +
+
+
+
+

+ + Jobs for You +

+

+ Real job listings matched to{" "} + {role ? ( + + {role} + + ) : ( + "your target role" + )}{" "} + — sourced from Adzuna and refreshed daily. +

+
+ +
+
+ setCustomRole(e.target.value)} + placeholder="Override role…" + className="px-3 py-2 text-sm rounded-xl border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-violet-500 w-40" + /> + + +
+
+
+
+ +
+ {loading ? ( +
+
+
+ ) : jobs.length > 0 ? ( + <> +

+ Showing {jobs.length} listings for{" "} + {role} +

+
+ {jobs.map((job) => ( + + ))} +
+ + ) : ( +
+
+ +
+

No listings found

+

+ No jobs matched your current role. Try a different role or country, or start a new interview session to set your target role. +

+
+ )} +
+
+ ); +}; + +export default JobsForYou; diff --git a/frontend/src/utils/apiPaths.js b/frontend/src/utils/apiPaths.js index 86e788d..e687879 100644 --- a/frontend/src/utils/apiPaths.js +++ b/frontend/src/utils/apiPaths.js @@ -40,5 +40,8 @@ export const API_PATHS = { ANALYZE: "/api/resume/analyze", // AI Resume Analyzer via Gemini SAVE: "/api/resume/save", // Save resume to backend GET_ALL: "/api/resume/my-resumes", // Get all user's saved resumes - } + }, + JOBS: { + GET: "/api/jobs", // GET /api/jobs?role=...&country=... + }, }; \ No newline at end of file From b0aa0b43213e93c3c8ca9573a15c80d49918dc2f Mon Sep 17 00:00:00 2001 From: GitGuru-sudo Date: Fri, 3 Jul 2026 21:58:59 +0530 Subject: [PATCH 2/2] fix: make Adzuna Jobs feature optional and fix failing API request - Remove ADZUNA_APP_ID/ADZUNA_API_KEY from required env vars so the server boots without them (previously process.exit(1) on missing keys took down the entire backend for anyone without Adzuna credentials) - Guard getJobs and refreshJobCache with isAdzunaConfigured() so the feature degrades gracefully instead of returning 500s when unset - Gate boot-time cache cron behind key presence in server.js - Drop invalid content_type query param that caused Adzuna to reject every search request with HTTP 400 --- backend/config/validateEnv.js | 6 ++++-- backend/controllers/jobController.js | 16 +++++++++++++++- backend/server.js | 11 +++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/config/validateEnv.js b/backend/config/validateEnv.js index afeae5d..7e3a77a 100644 --- a/backend/config/validateEnv.js +++ b/backend/config/validateEnv.js @@ -2,10 +2,12 @@ const requiredEnvVars = Object.freeze([ "MONGO_URI", "JWT_SECRET", "GEMINI_API_KEY", - "ADZUNA_APP_ID", - "ADZUNA_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) + const validateEnv = () => { const missingVars = []; diff --git a/backend/controllers/jobController.js b/backend/controllers/jobController.js index c19a55d..c03ce68 100644 --- a/backend/controllers/jobController.js +++ b/backend/controllers/jobController.js @@ -7,6 +7,10 @@ const ADZUNA_API_KEY = process.env.ADZUNA_API_KEY; const ADZUNA_COUNTRY = process.env.ADZUNA_COUNTRY || "in"; 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. +const isAdzunaConfigured = () => Boolean(ADZUNA_APP_ID && ADZUNA_API_KEY); + 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, { @@ -15,7 +19,6 @@ async function fetchFromAdzuna(role, country = ADZUNA_COUNTRY) { app_key: ADZUNA_API_KEY, what: role, results_per_page: 10, - content_type: "application/json", }, }); return (data.results || []).map((j) => ({ @@ -33,6 +36,16 @@ async function fetchFromAdzuna(role, country = ADZUNA_COUNTRY) { exports.getJobs = async (req, res) => { try { + if (!isAdzunaConfigured()) { + return res.json({ + jobs: [], + role: "", + source: "disabled", + message: + "Job listings are not configured on this server. Set ADZUNA_APP_ID and ADZUNA_API_KEY to enable them.", + }); + } + const userId = req.user._id; const latestSession = await Session.findOne({ user: userId }) @@ -64,6 +77,7 @@ exports.getJobs = async (req, res) => { }; exports.refreshJobCache = async () => { + if (!isAdzunaConfigured()) return; try { const roles = await Session.distinct("role", { createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, diff --git a/backend/server.js b/backend/server.js index 18a5dc6..d29eb43 100644 --- a/backend/server.js +++ b/backend/server.js @@ -142,10 +142,13 @@ app.get("/api/test", (req, res) => { // Remove duplicate CORS middleware (already set above) -// Daily job cache refresh — warm on boot, then every 24 hours -const { refreshJobCache } = require("./controllers/jobController"); -refreshJobCache(); -setInterval(refreshJobCache, 24 * 60 * 60 * 1000); +// Daily job cache refresh — warm on boot, then every 24 hours. +// Only runs when Adzuna is configured; otherwise refreshJobCache() no-ops. +if (process.env.ADZUNA_APP_ID && process.env.ADZUNA_API_KEY) { + const { refreshJobCache } = require("./controllers/jobController"); + refreshJobCache(); + setInterval(refreshJobCache, 24 * 60 * 60 * 1000); +} // Start Server const PORT = process.env.PORT || 5000;