Skip to content
Merged

254 #289

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
13 changes: 9 additions & 4 deletions backend/controllers/referral.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ async function awardFirstReferralBadge(userId) {
async function getReferrals(req, res, next) {
try {
const { status = 'open', search, page = 1, limit = 10 } = req.query;
const skip = (page - 1) * limit;

const pageNum = Math.max(1, Number(page));
const limitNum = Math.max(1, Number(limit));
const skip = (pageNum - 1) * limitNum;

const currentUser = req.user?.id ? await getCurrentUserSummary(req.user.id) : null;

const query = { status };
Expand All @@ -201,20 +205,21 @@ async function getReferrals(req, res, next) {
.populate('postedBy', 'name email alumnus_bio type referralPostingSuspended referralPostingSuspendedReason')
.populate('applicants.user', 'name email')
.sort({ createdAt: -1 })
.skip(skip * 1)
.limit(limit * 1);
.skip(skip)
.limit(limitNum);

const total = await JobReferral.countDocuments(query);

res.json({
referrals,
pagination: { total, page: parseInt(page), limit: parseInt(limit) }
pagination: { total, page: pageNum, limit: limitNum }
});
} catch (err) {
next(err);
}
}


// Apply for a referral
async function applyForReferral(req, res, next) {
try {
Expand Down
103 changes: 90 additions & 13 deletions backend/controllers/resumeAnalyzer.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,37 +234,114 @@ async function analyzeResume(req, res, next) {
const missingSkills = skillExplanations.filter((entry) => entry.status === 'missing').map((entry) => entry.skill);

// Course suggestions: match missing skills against course tags/title (best-effort)
// We still return generic recommendations even if no matching courses are found.
// Fix: avoid N+1 DB queries by doing a single batched query for all missing skills (up to 8).
const missingTop = missingSkills.slice(0, 8);

const courseSuggestions = [];
if (missingTop.length > 0) {
for (const skill of missingTop) {
const escapeRegExp = (value) => String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const skillRegexes = missingTop
.map((skill) => ({
skill: String(skill).trim(),
regex: new RegExp(escapeRegExp(String(skill)), 'i'),
}))
.filter((x) => x.skill.length > 0);

if (skillRegexes.length > 0) {
// Single query that returns candidates matched by ANY missing skill.
const orConditions = [
...skillRegexes.map((sr) => ({ title: { $regex: sr.regex } })),
...skillRegexes.map((sr) => ({
tags: { $elemMatch: { $regex: sr.regex } },
})),
...skillRegexes.map((sr) => ({ description: { $regex: sr.regex } })),
];

const courses = await Course.find({
isPublished: true,
$or: [
{ title: { $regex: new RegExp(skill, 'i') } },
{ tags: { $elemMatch: { $regex: new RegExp(skill, 'i') } } },
{ description: { $regex: new RegExp(skill, 'i') } },
],
$or: orConditions,
})
.limit(3)
// Upper bound; we post-process to pick best matches.
.limit(60)
.lean();

if (courses && courses.length > 0) {
const getSkillScoreForCourse = (course, skillEntry) => {
// Important: RegExp#test is stateful for /g. We never use /g, but cloning keeps it safe.
const regex = new RegExp(skillEntry.regex.source, skillEntry.regex.flags);

const title = String(course?.title || '');
const description = String(course?.description || '');
const tags = Array.isArray(course?.tags) ? course.tags : [];

let score = 0;

if (regex.test(title)) score += 6;
if (regex.test(description)) score += 3;
if (tags.some((t) => regex.test(String(t)))) score += 5;

const skillLower = String(skillEntry.skill).toLowerCase().trim();
if (skillLower && title.toLowerCase().includes(skillLower)) score += 2;

return score;
};


for (const c of courses) {
let bestSkill = null;
let bestScore = 0;

for (const sr of skillRegexes) {
const s = getSkillScoreForCourse(c, sr);
if (s > bestScore) {
bestScore = s;
bestSkill = sr.skill;
}
}

if (bestSkill && bestScore > 0) {
courseSuggestions.push({
skill: bestSkill,
courseId: c._id,
title: c.title,
category: c.category,
_score: bestScore,
});
}
}

courseSuggestions.sort((a, b) => (b._score || 0) - (a._score || 0));

const seen = new Set();
const deduped = [];
for (const cs of courseSuggestions) {
const key = String(cs.courseId);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(cs);
}

courseSuggestions.length = 0;
courseSuggestions.push(
...courses.map((c) => ({
skill,
courseId: c._id,
title: c.title,
category: c.category,
...deduped.map((cs) => ({
skill: cs.skill,
courseId: cs.courseId,
title: cs.title,
category: cs.category,
}))
);
}
}
}






const recommendations = buildRecommendations({ missingSkills, score });

if (courseSuggestions.length > 0) {
recommendations.push('Suggested courses based on your skill gaps:');
for (const cs of courseSuggestions.slice(0, 6)) {
Expand Down
39 changes: 29 additions & 10 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,39 @@ connectDB();
========================= */

const normalizeOrigin = (origin = '') =>
origin.trim().replace(/\/+$/, '');
String(origin).trim().replace(/\/+$/, '');

const DEFAULT_ORIGINS = [
'http://localhost:5173',
'https://alumni-management-system-frontend.onrender.com',
'https://alumni-management-system-xi.vercel.app'
];

const ENV_ORIGINS = [
process.env.FRONTEND_URL,
...(process.env.FRONTEND_URLS || '')
.split(',')
const parseOriginsEnv = () => {
const single = process.env.FRONTEND_URL;
const multipleRaw = process.env.FRONTEND_URLS;

const multiple = typeof multipleRaw === 'string'
? multipleRaw.split(',')
: [];

return [single, ...multiple]
.map((origin) => normalizeOrigin(origin))
.filter(Boolean)
];
.filter(Boolean);
};

const CLIENT_ORIGINS = [...new Set(
[...DEFAULT_ORIGINS, ...ENV_ORIGINS]
[...DEFAULT_ORIGINS, ...parseOriginsEnv()]
.map((origin) => normalizeOrigin(origin))
.filter(Boolean)
)];

console.log('Allowed CORS origins:', CLIENT_ORIGINS);
if (process.env.NODE_ENV === 'development') {
console.log('Allowed CORS origins:', CLIENT_ORIGINS);
}
logger.logInfo('Server initialized with logging system enabled');


const corsOptions = {
origin: (origin, callback) => {
// Allow requests without origin (Postman, curl, health checks)
Expand Down Expand Up @@ -201,7 +209,18 @@ const server = http.createServer(app);
// Initialize Socket.IO with CORS configuration
const io = socketIO(server, {
cors: {
origin: CLIENT_ORIGINS,
origin: (origin, callback) => {
// Socket.IO needs exact origin match; normalize to reduce intermittent failures
if (!origin) return callback(null, true);

const normalizedOrigin = normalizeOrigin(origin);

if (CLIENT_ORIGINS.includes(normalizedOrigin)) {
return callback(null, true);
}

return callback(new Error(`Socket CORS blocked for origin: ${origin}`));
},
methods: ['GET', 'POST'],
credentials: true
},
Expand Down
32 changes: 25 additions & 7 deletions frontend/src/components/ReferralList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ const ReferralList = () => {
const [referrals, setReferrals] = useState([]);
const [pagination, setPagination] = useState({ total: 0, page: 1, limit: 10 });
const [loading, setLoading] = useState(true);
const [loadingNextPage, setLoadingNextPage] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('open');
const [page, setPage] = useState(1);
const [savedItems, setSavedItems] = useState([]);
const limit = 10;


useEffect(() => {
// Fetch whenever filter/search/page changes.
// Pagination state is always synced from the server inside fetchReferrals().
Expand All @@ -27,6 +29,7 @@ const ReferralList = () => {




useEffect(() => {
if (!user) {
setSavedItems([]);
Expand All @@ -51,19 +54,24 @@ const ReferralList = () => {
}, [user]);

const fetchReferrals = async ({ page: requestedPage, search: requestedSearch } = {}) => {
const requestedPageNum = Number(requestedPage ?? 1);
const isPageNavigation = requestedPageNum !== Number(pagination?.page);

try {
setLoading(true);
setError('');
if (isPageNavigation) {
setLoadingNextPage(true);
} else {
setLoading(true);
}

setError('');

const params = {
status: statusFilter,
page: requestedPage ?? 1,
limit
};


const effectiveSearch = requestedSearch ?? '';
if (effectiveSearch) params.search = effectiveSearch;

Expand All @@ -82,9 +90,11 @@ const ReferralList = () => {
toast.error('Failed to load referrals');
} finally {
setLoading(false);
setLoadingNextPage(false);
}
};


const handleSearch = (e) => {
e.preventDefault();
setPage(1);
Expand All @@ -100,6 +110,7 @@ const ReferralList = () => {
);
}


const savedMap = savedItems.reduce((accumulator, item) => {
accumulator[`${item.entityType}:${item.entityId}`] = item;
return accumulator;
Expand Down Expand Up @@ -286,24 +297,31 @@ const ReferralList = () => {
{/* Pagination */}
{pagination.total > 0 && (
<div className="flex justify-center mt-12">
<div className="flex gap-2">
<div className="flex gap-2 items-center">

<button
onClick={() => {
const targetPage = Math.max(1, page - 1);
setPage(targetPage);
}}
disabled={page === 1 || loading}
disabled={page === 1 || loadingNextPage || loading}

className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors"
>
Previous
</button>
<span className="px-4 py-2 font-medium">Page {page}</span>
<span className="px-4 py-2 font-medium flex items-center gap-2">
Page {page}
{loadingNextPage && <span className="text-sm text-gray-500">(Loading…)</span>}
</span>

<button
onClick={() => {
const targetPage = page + 1;
setPage(targetPage);
}}
disabled={page * limit >= pagination.total || loading}
disabled={page * limit >= pagination.total || loadingNextPage || loading}

className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors"
>
Next
Expand Down
Loading