-
+
Department Performance Report
Generated at: {reportsData?.generatedAt ? new Date(reportsData.generatedAt).toLocaleString() : 'Just now'} | Total Records: {reportsData?.totalRecords || 0}
-
+
+
+
+
+
)}
diff --git a/server/src/config/invitationConfig.js b/server/src/config/invitationConfig.js
index a0b0716..f044031 100644
--- a/server/src/config/invitationConfig.js
+++ b/server/src/config/invitationConfig.js
@@ -1,34 +1,32 @@
/**
* Centralized Administrator and User Invitation Configuration
*
- * Provides authoritative calculation of invitation expiration and rate limiting
- * based on environment variables or default policies (24 hours).
+ * Provides authoritative calculation of invitation expiration (10 minutes)
+ * and rate limiting based on centralized security policies.
*/
-const getAdminInvitationExpiryHours = () => {
- const envVal = process.env.ADMIN_INVITATION_EXPIRY_HOURS;
- const parsed = parseInt(envVal, 10);
- return !isNaN(parsed) && parsed > 0 ? parsed : 24;
-};
+const {
+ getSecurityTokenExpiryMinutes,
+ getSecurityTokenExpiresAt,
+ getResendRateLimitConfig,
+} = require('./securityTokenConfig');
-const getAdminInvitationExpiresAt = (fromDate = new Date()) => {
- const hours = getAdminInvitationExpiryHours();
- return new Date(new Date(fromDate).getTime() + hours * 3600 * 1000);
+const getAdminInvitationExpiryMinutes = () => {
+ return getSecurityTokenExpiryMinutes();
};
-const getResendRateLimitConfig = () => {
- const maxAttempts = parseInt(process.env.ADMIN_INVITATION_RESEND_LIMIT || '5', 10);
- const windowMinutes = parseInt(process.env.ADMIN_INVITATION_RESEND_WINDOW_MINUTES || '60', 10);
- const cooldownSeconds = parseInt(process.env.ADMIN_INVITATION_RESEND_COOLDOWN_SECONDS || '60', 10);
+const getAdminInvitationExpiryHours = () => {
+ // Return minutes / 60 or minutes as formatted string/number for backward compatibility
+ const minutes = getSecurityTokenExpiryMinutes();
+ return minutes;
+};
- return {
- maxAttempts: isNaN(maxAttempts) ? 5 : maxAttempts,
- windowMinutes: isNaN(windowMinutes) ? 60 : windowMinutes,
- cooldownSeconds: isNaN(cooldownSeconds) ? 60 : cooldownSeconds,
- };
+const getAdminInvitationExpiresAt = (fromDate = new Date()) => {
+ return getSecurityTokenExpiresAt(fromDate);
};
module.exports = {
+ getAdminInvitationExpiryMinutes,
getAdminInvitationExpiryHours,
getAdminInvitationExpiresAt,
getResendRateLimitConfig,
diff --git a/server/src/config/securityTokenConfig.js b/server/src/config/securityTokenConfig.js
new file mode 100644
index 0000000..ea59144
--- /dev/null
+++ b/server/src/config/securityTokenConfig.js
@@ -0,0 +1,67 @@
+/**
+ * Centralized Security Token & Email Link Expiration Configuration
+ *
+ * Enforces authoritative 10-minute maximum lifetime for all security-sensitive
+ * email verification, account activation, administrative invitation, password setup,
+ * password reset, and email change tokens across MAVI Linking.
+ */
+
+const DEFAULT_SECURITY_TOKEN_EXPIRY_MINUTES = 10;
+
+/**
+ * Get configured token expiry duration in minutes
+ * @returns {number} 10 minutes (or process.env.SECURITY_TOKEN_EXPIRY_MINUTES if defined)
+ */
+const getSecurityTokenExpiryMinutes = () => {
+ const envVal = process.env.SECURITY_TOKEN_EXPIRY_MINUTES || process.env.ADMIN_INVITATION_EXPIRY_MINUTES;
+ const parsed = parseInt(envVal, 10);
+ return !isNaN(parsed) && parsed > 0 ? parsed : DEFAULT_SECURITY_TOKEN_EXPIRY_MINUTES;
+};
+
+/**
+ * Calculate token expiration Date object (Current Time + 10 Minutes)
+ * @param {Date|number|string} [fromDate=new Date()]
+ * @returns {Date}
+ */
+const getSecurityTokenExpiresAt = (fromDate = new Date()) => {
+ const minutes = getSecurityTokenExpiryMinutes();
+ const baseTime = fromDate instanceof Date ? fromDate.getTime() : new Date(fromDate).getTime();
+ return new Date(baseTime + minutes * 60 * 1000);
+};
+
+/**
+ * Authoritatively check if a token timestamp has expired against the current server time
+ * @param {Date|number|string|null} expiresAt
+ * @param {Date|number} [now=Date.now()]
+ * @returns {boolean} true if expired or missing, false if still valid
+ */
+const isTokenExpired = (expiresAt, now = Date.now()) => {
+ if (!expiresAt) return true;
+ const expiryTime = expiresAt instanceof Date ? expiresAt.getTime() : new Date(expiresAt).getTime();
+ if (isNaN(expiryTime)) return true;
+ const currentTime = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : Date.now();
+ return currentTime >= expiryTime;
+};
+
+/**
+ * Resend Rate-Limiting Configuration (60s minimum cooldown between email dispatches)
+ */
+const getResendRateLimitConfig = () => {
+ const maxAttempts = parseInt(process.env.RESEND_RATE_LIMIT_MAX_ATTEMPTS || '5', 10);
+ const cooldownSeconds = parseInt(process.env.RESEND_RATE_LIMIT_COOLDOWN_SECONDS || '60', 10);
+ const windowMinutes = parseInt(process.env.RESEND_RATE_LIMIT_WINDOW_MINUTES || '60', 10);
+
+ return {
+ maxAttempts: isNaN(maxAttempts) ? 5 : maxAttempts,
+ cooldownSeconds: isNaN(cooldownSeconds) ? 60 : cooldownSeconds,
+ windowMinutes: isNaN(windowMinutes) ? 60 : windowMinutes,
+ };
+};
+
+module.exports = {
+ DEFAULT_SECURITY_TOKEN_EXPIRY_MINUTES,
+ getSecurityTokenExpiryMinutes,
+ getSecurityTokenExpiresAt,
+ isTokenExpired,
+ getResendRateLimitConfig,
+};
diff --git a/server/src/controllers/authController.js b/server/src/controllers/authController.js
index c967961..4cdfbd9 100644
--- a/server/src/controllers/authController.js
+++ b/server/src/controllers/authController.js
@@ -11,6 +11,7 @@ const EmailChangeChallenge = require('../models/EmailChangeChallenge');
const { sendEmail, generateEmailChangeOtpEmailHtml, generateEmailChangeNotificationOldEmailHtml } = require('../utils/sendEmail');
const { getIO } = require('../config/socket');
const { getAdminInvitationExpiryHours } = require('../config/invitationConfig');
+const { getSecurityTokenExpiryMinutes, getSecurityTokenExpiresAt, isTokenExpired } = require('../config/securityTokenConfig');
const googleClientId = process.env.GOOGLE_CLIENT_ID || process.env.VITE_GOOGLE_CLIENT_ID;
const oauth2Client = new OAuth2Client(googleClientId);
@@ -264,7 +265,7 @@ const register = async (req, res, next) => {
// Generate cryptographic verification token (SHA-256 hashed in DB)
const rawVerificationToken = crypto.randomBytes(32).toString('hex');
const hashedVerificationToken = crypto.createHash('sha256').update(rawVerificationToken).digest('hex');
- const tokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
+ const tokenExpires = getSecurityTokenExpiresAt(); // 10 minutes
userData.verificationToken = hashedVerificationToken;
userData.verificationTokenExpires = tokenExpires;
@@ -281,7 +282,7 @@ const register = async (req, res, next) => {
const emailHtml = generateStudentVerificationEmailHtml({
name: user.name,
verificationLink,
- expiresHours: 24,
+ expiresMinutes: getSecurityTokenExpiryMinutes(),
});
sendEmail({
@@ -812,8 +813,8 @@ const verifyEmail = async (req, res, next) => {
});
}
- // Expiration check
- if (user.verificationTokenExpires && user.verificationTokenExpires < Date.now()) {
+ // Expiration check (10-minute lifetime enforced server-side)
+ if (isTokenExpired(user.verificationTokenExpires)) {
try {
await AuditLog.create({
actorId: user._id,
@@ -963,10 +964,11 @@ const resendVerification = async (req, res, next) => {
}
// Rate limit check: 60 seconds minimum between resends
+ const expiryMinutes = getSecurityTokenExpiryMinutes();
if (user.verificationTokenExpires) {
- const lastSentTime = new Date(user.verificationTokenExpires.getTime() - 24 * 60 * 60 * 1000);
+ const lastSentTime = new Date(user.verificationTokenExpires.getTime() - expiryMinutes * 60 * 1000);
const secondsSinceLastSent = (Date.now() - lastSentTime.getTime()) / 1000;
- if (secondsSinceLastSent < 60) {
+ if (secondsSinceLastSent >= 0 && secondsSinceLastSent < 60) {
const secondsToWait = Math.ceil(60 - secondsSinceLastSent);
return res.status(429).json({
success: false,
@@ -976,13 +978,14 @@ const resendVerification = async (req, res, next) => {
}
}
- // Generate new token (24-hour expiry)
+ // Generate new token (10-minute expiry)
const rawVerificationToken = crypto.randomBytes(32).toString('hex');
const hashedVerificationToken = crypto.createHash('sha256').update(rawVerificationToken).digest('hex');
- const tokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ const tokenExpires = getSecurityTokenExpiresAt();
user.verificationToken = hashedVerificationToken;
user.verificationTokenExpires = tokenExpires;
+ user.verificationTokenPurpose = 'ACCOUNT_EMAIL_VERIFICATION';
user.accountStatus = 'PENDING_VERIFICATION';
await user.save();
@@ -993,7 +996,7 @@ const resendVerification = async (req, res, next) => {
const emailHtml = generateStudentVerificationEmailHtml({
name: user.name,
verificationLink,
- expiresHours: 24,
+ expiresMinutes: expiryMinutes,
});
const emailResult = await sendEmail({
@@ -1098,11 +1101,12 @@ const changeEmailPending = async (req, res, next) => {
const oldEmail = user.email;
user.email = normalizedNewEmail;
- // Issue new verification token & invalidate old token
+ // Issue new verification token & invalidate old token (10-minute expiry)
const rawVerificationToken = crypto.randomBytes(32).toString('hex');
const hashedVerificationToken = crypto.createHash('sha256').update(rawVerificationToken).digest('hex');
user.verificationToken = hashedVerificationToken;
- user.verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ user.verificationTokenExpires = getSecurityTokenExpiresAt();
+ user.verificationTokenPurpose = 'ACCOUNT_EMAIL_VERIFICATION';
user.emailVerified = false;
user.accountStatus = 'PENDING_VERIFICATION';
@@ -1115,7 +1119,7 @@ const changeEmailPending = async (req, res, next) => {
const emailHtml = generateStudentVerificationEmailHtml({
name: user.name,
verificationLink,
- expiresHours: 24,
+ expiresMinutes: getSecurityTokenExpiryMinutes(),
});
const emailResult = await sendEmail({
@@ -1211,7 +1215,7 @@ const forgotPassword = async (req, res, next) => {
user.resetPasswordToken = hashedToken;
user.resetPasswordOtp = hashedOtp;
- user.resetPasswordExpires = Date.now() + 15 * 60 * 1000; // 15 minutes
+ user.resetPasswordExpires = getSecurityTokenExpiresAt(); // 10 minutes
await user.save();
// Log recovery request event
@@ -1833,7 +1837,7 @@ const verifyAdminInvite = async (req, res, next) => {
});
}
- if (user.invitationExpires && new Date() > new Date(user.invitationExpires)) {
+ if (isTokenExpired(user.invitationExpires)) {
try {
await AuditLog.create({
actorId: user._id,
@@ -1879,7 +1883,7 @@ const verifyAdminInvite = async (req, res, next) => {
institution: user.institutionId,
department: user.departmentId,
expiresAt: user.invitationExpires,
- validityHours: getAdminInvitationExpiryHours(),
+ validityMinutes: getSecurityTokenExpiryMinutes(),
},
});
} catch (error) {
@@ -1978,7 +1982,7 @@ const acceptAdminInvite = async (req, res, next) => {
});
}
- if (user.invitationExpires && new Date() > new Date(user.invitationExpires)) {
+ if (isTokenExpired(user.invitationExpires)) {
try {
await AuditLog.create({
actorId: user._id,
@@ -2267,7 +2271,7 @@ const activateAccount = async (req, res, next) => {
// Set new password (hashed via pre-save hook)
user.password = password;
- // Activate Account State
+ // Activate Account State & Invalidate single-use token
user.emailVerified = true;
user.accountStatus = 'ACTIVE';
user.status = 'active';
@@ -2275,6 +2279,8 @@ const activateAccount = async (req, res, next) => {
user.passwordSetupRequired = false;
user.mustChangePassword = false;
user.passwordChangedAt = Date.now();
+ user.invitationToken = null;
+ user.invitationExpires = null;
// Generate JWT and Refresh Token for seamless session setup upon activation
const authToken = user.generateAuthToken();
@@ -2411,13 +2417,13 @@ const requestEmailChange = async (req, res, next) => {
console.log(`[EMAIL CHANGE OTP DISPATCHED] User: ${user.email} -> New Email: ${canonicalNewEmail} | 6-Digit OTP: ${otp}`);
- // 6. Create EmailChangeChallenge document (valid for 15 minutes)
+ // 6. Create EmailChangeChallenge document (valid for 10 minutes)
await EmailChangeChallenge.create({
userId: user._id,
newEmail: canonicalNewEmail,
hashedOtp,
purpose: 'EMAIL_CHANGE',
- expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
+ expiresAt: getSecurityTokenExpiresAt(), // 10 minutes
status: 'PENDING',
lastResendAt: new Date(),
});
@@ -2448,10 +2454,10 @@ const requestEmailChange = async (req, res, next) => {
res.status(200).json({
success: true,
- message: `A 6-digit verification code has been sent to ${canonicalNewEmail}. Please verify within 15 minutes.`,
+ message: `A 6-digit verification code has been sent to ${canonicalNewEmail}. Please verify within 10 minutes.`,
data: {
newEmail: canonicalNewEmail,
- expiresInMinutes: 15,
+ expiresInMinutes: getSecurityTokenExpiryMinutes(),
},
});
} catch (error) {
diff --git a/server/src/controllers/departmentDashboardController.js b/server/src/controllers/departmentDashboardController.js
index 6e717cc..ec5ed8d 100644
--- a/server/src/controllers/departmentDashboardController.js
+++ b/server/src/controllers/departmentDashboardController.js
@@ -4,6 +4,7 @@ const Project = require('../models/Project');
const TeacherAnnouncement = require('../models/TeacherAnnouncement');
const AuditLog = require('../models/AuditLog');
const { PRIVILEGED_ROLES, calculateScoreTier, calculateMedal } = require('../utils/leaderboardHelper');
+const { generateDepartmentReportData, writeDepartmentReportPdf } = require('../services/departmentReportService');
/**
* Build department & institution scope query from request object
@@ -376,25 +377,31 @@ const getDepartmentAnalytics = async (req, res, next) => {
*/
const getDepartmentReports = async (req, res, next) => {
try {
- const query = buildScopeQuery(req, { role: { $in: ['user', 'student', 'developer'] } });
- const students = await User.find(query)
- .select('name email maviId prn scores status skillsList platforms createdAt')
- .sort({ 'scores.overall': -1 });
+ const reportData = await generateDepartmentReportData(req);
res.status(200).json({
success: true,
- data: {
- generatedAt: new Date().toISOString(),
- totalRecords: students.length,
- reportType: 'DEPARTMENT_STUDENT_PERFORMANCE',
- students,
- },
+ data: reportData,
});
} catch (error) {
next(error);
}
};
+/**
+ * @desc Export department-scoped performance report as PDF
+ * @route GET /api/department-admin/reports/pdf
+ * @access Private (Department Admin)
+ */
+const exportDepartmentReportPdf = async (req, res, next) => {
+ try {
+ const reportData = await generateDepartmentReportData(req);
+ await writeDepartmentReportPdf(reportData, res);
+ } catch (error) {
+ next(error);
+ }
+};
+
/**
* @desc Get department student leaderboard (excluding privileged accounts)
* @route GET /api/department-admin/leaderboard
@@ -456,5 +463,6 @@ module.exports = {
getDepartmentTeachers,
getDepartmentAnalytics,
getDepartmentReports,
+ exportDepartmentReportPdf,
getDepartmentLeaderboard,
};
diff --git a/server/src/controllers/platformController.js b/server/src/controllers/platformController.js
index 91657e2..9aa951f 100644
--- a/server/src/controllers/platformController.js
+++ b/server/src/controllers/platformController.js
@@ -146,14 +146,14 @@ const linkPlatform = async (req, res, next) => {
{ platform }
);
- // Sync GitHub activity after successfully linking a GitHub account.
- // Activity sync failure should not prevent the account from being linked.
+ // Sync GitHub intelligence and activities after successfully linking a GitHub account.
+ // Sync failure should not prevent the account from being linked.
if (platform === 'github') {
try {
- const { syncGitHubActivities } = require('../services/githubActivityService');
- await syncGitHubActivities(user._id);
+ const { syncGitHubAccount } = require('../services/githubSyncService');
+ await syncGitHubAccount(user._id, sanitized.username);
} catch (syncError) {
- console.error('GitHub activity sync failed:', syncError.message);
+ console.error('Initial GitHub sync warning:', syncError.message);
}
}
@@ -547,7 +547,7 @@ const getGitHubIntelligence = async (req, res, next) => {
const Activity = require('../models/Activity');
const { calculateDevelopmentScore } = require('../services/scoreService');
- const user = await User.findById(req.user.id);
+ let user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}
@@ -558,20 +558,45 @@ const getGitHubIntelligence = async (req, res, next) => {
success: true,
data: {
linked: false,
+ username: null,
intelligence: null,
scores: user.scores || {},
breakdown: null,
+ totalScore: 0,
isVerified: Boolean(user.isVerified),
+ lastSyncedAt: null,
+ isFresh: false,
+ freshnessMinutes: null,
},
});
}
+ let githubData = user.platformData?.github || null;
+
+ // Auto-sync if linked but never populated
+ if (!githubData && githubUsername) {
+ try {
+ const { syncGitHubAccount } = require('../services/githubSyncService');
+ const syncResult = await syncGitHubAccount(user._id, githubUsername);
+ if (syncResult.success && syncResult.data) {
+ githubData = syncResult.data;
+ user = await User.findById(req.user.id);
+ }
+ } catch (autoSyncErr) {
+ console.warn(`[GitHub Intelligence] Auto-sync on load warning for ${githubUsername}:`, autoSyncErr.message);
+ }
+ }
+
const projects = await Project.find({ user: req.user.id });
const activities = await Activity.find({ userId: req.user.id, platform: 'github' }).sort({ date: -1 }).limit(50);
- const githubData = user.platformData?.github || null;
const scoreResult = calculateDevelopmentScore(githubData, projects, activities);
+ const lastSyncedAt = user.lastSyncedAt || githubData?.sync?.lastSyncedAt || null;
+ const now = Date.now();
+ const freshnessMinutes = lastSyncedAt ? Math.floor((now - new Date(lastSyncedAt).getTime()) / 60000) : null;
+ const isFresh = freshnessMinutes !== null ? freshnessMinutes <= 15 : false;
+
res.status(200).json({
success: true,
data: {
@@ -582,7 +607,10 @@ const getGitHubIntelligence = async (req, res, next) => {
breakdown: scoreResult.breakdown,
totalScore: scoreResult.totalScore,
isVerified: Boolean(user.isVerified),
- lastSyncedAt: user.lastSyncedAt || githubData?.sync?.lastSyncedAt || null,
+ lastSyncedAt,
+ isFresh,
+ freshnessMinutes,
+ syncStatus: githubData?.sync?.status || (lastSyncedAt ? 'complete' : 'never_synced'),
},
});
} catch (error) {
@@ -597,14 +625,36 @@ const getGitHubIntelligence = async (req, res, next) => {
*/
const syncGitHubIntelligence = async (req, res, next) => {
try {
+ const Project = require('../models/Project');
+ const Activity = require('../models/Activity');
+ const { calculateDevelopmentScore } = require('../services/scoreService');
const { syncGitHubAccount } = require('../services/githubSyncService');
+
const result = await syncGitHubAccount(req.user.id);
+ const githubData = result.data;
+ const updatedUser = result.user || (await User.findById(req.user.id));
+
+ const projects = await Project.find({ user: req.user.id });
+ const activities = await Activity.find({ userId: req.user.id, platform: 'github' }).sort({ date: -1 }).limit(50);
+ const scoreResult = calculateDevelopmentScore(githubData, projects, activities);
res.status(200).json({
success: true,
message: result.message || 'GitHub intelligence synchronized successfully',
- data: result.data,
- user: result.user,
+ data: {
+ linked: true,
+ username: updatedUser.platforms?.github?.username || updatedUser.githubUsername,
+ intelligence: githubData,
+ scores: updatedUser.scores || {},
+ breakdown: scoreResult.breakdown,
+ totalScore: scoreResult.totalScore,
+ isVerified: Boolean(updatedUser.isVerified),
+ lastSyncedAt: updatedUser.lastSyncedAt || new Date(),
+ isFresh: true,
+ freshnessMinutes: 0,
+ syncStatus: githubData?.sync?.status || 'complete',
+ },
+ user: updatedUser,
});
} catch (error) {
next(error);
diff --git a/server/src/models/Activity.js b/server/src/models/Activity.js
index 5912727..02ad69b 100644
--- a/server/src/models/Activity.js
+++ b/server/src/models/Activity.js
@@ -9,7 +9,24 @@ const activitySchema = new mongoose.Schema(
},
type: {
type: String,
- enum: ['Commit', 'Repository', 'Pull Request', 'Contest', 'Milestone', 'Project', 'LeetCode', 'Certificate', 'Document', 'Profile', 'Other'],
+ enum: [
+ 'Commit',
+ 'Repository',
+ 'Pull Request',
+ 'Issue',
+ 'Release',
+ 'Contest',
+ 'Milestone',
+ 'Project',
+ 'LeetCode',
+ 'Certificate',
+ 'Document',
+ 'Profile',
+ 'Starred',
+ 'Fork',
+ 'Other',
+ ],
+ default: 'Other',
required: true
},
title: { type: String, required: true },
diff --git a/server/src/models/User.js b/server/src/models/User.js
index b8bb12f..e165a27 100644
--- a/server/src/models/User.js
+++ b/server/src/models/User.js
@@ -299,7 +299,7 @@ const userSchema = new mongoose.Schema(
githubUsername: { type: String, default: '' },
preferredDomain: {
type: String,
- enum: ['', 'Web Development', 'AI/ML', 'Competitive Programming', 'Cybersecurity', 'App Development'],
+ trim: true,
default: '',
},
experienceLevel: {
diff --git a/server/src/routes/departmentAdminRoutes.js b/server/src/routes/departmentAdminRoutes.js
index f74f28c..0685c59 100644
--- a/server/src/routes/departmentAdminRoutes.js
+++ b/server/src/routes/departmentAdminRoutes.js
@@ -9,6 +9,7 @@ const {
getDepartmentTeachers,
getDepartmentAnalytics,
getDepartmentReports,
+ exportDepartmentReportPdf,
getDepartmentLeaderboard,
} = require('../controllers/departmentDashboardController');
@@ -24,6 +25,7 @@ router.patch('/students/:studentId/profile', updateDepartmentStudentProfile);
router.get('/teachers', getDepartmentTeachers);
router.get('/analytics', getDepartmentAnalytics);
router.get('/reports', getDepartmentReports);
+router.get('/reports/pdf', exportDepartmentReportPdf);
router.get('/leaderboard', getDepartmentLeaderboard);
module.exports = router;
diff --git a/server/src/services/departmentReportService.js b/server/src/services/departmentReportService.js
new file mode 100644
index 0000000..f41ef09
--- /dev/null
+++ b/server/src/services/departmentReportService.js
@@ -0,0 +1,373 @@
+const PDFDocument = require('pdfkit');
+const User = require('../models/User');
+const Department = require('../models/Department');
+const { calculateScoreTier } = require('../utils/leaderboardHelper');
+
+/**
+ * Format date for display
+ */
+const formatDateTime = (date) => {
+ const d = date instanceof Date ? date : new Date(date);
+ if (isNaN(d.getTime())) return new Date().toLocaleString();
+ return d.toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: true,
+ });
+};
+
+/**
+ * Build department & institution scope query from request object
+ */
+const buildScopeQuery = (req, baseRoleQuery = {}) => {
+ const query = { ...baseRoleQuery };
+
+ if (req.departmentScope?.departmentId) {
+ query.departmentId = req.departmentScope.departmentId;
+ } else if (req.user?.departmentId) {
+ query.departmentId = req.user.departmentId;
+ }
+
+ if (req.departmentScope?.institutionId) {
+ query.institutionId = req.departmentScope.institutionId;
+ } else if (req.user?.institutionId) {
+ query.institutionId = req.user.institutionId;
+ }
+
+ return query;
+};
+
+/**
+ * Generate normalized department performance report data
+ */
+const generateDepartmentReportData = async (req) => {
+ const departmentId = req.departmentScope?.departmentId || req.user?.departmentId;
+ const department = departmentId
+ ? await Department.findById(departmentId).populate('institutionId', 'name code tenantId')
+ : null;
+
+ const departmentName = department?.name || req.user?.university?.department || 'Department Administration';
+ const departmentCode = department?.code || '';
+ const institutionName = department?.institutionId?.name || req.user?.university?.name || 'Zeal College';
+
+ const query = buildScopeQuery(req, { role: { $in: ['user', 'student', 'developer'] } });
+ const students = await User.find(query)
+ .select('name email maviId prn scores status accountStatus isVerified skillsList platforms placementStatus placementReadinessScore profileCompletion createdAt')
+ .sort({ 'scores.overall': -1, createdAt: -1 })
+ .lean();
+
+ const totalStudents = students.length;
+ let activeStudents = 0;
+ let verifiedStudents = 0;
+ let totalScore = 0;
+ let totalDev = 0;
+ let totalProblem = 0;
+ let totalKnowledge = 0;
+ let totalReadiness = 0;
+ let totalProfileComp = 0;
+ let githubLinkedCount = 0;
+ let leetcodeLinkedCount = 0;
+
+ const tierDistribution = { Beginner: 0, Developing: 0, Intermediate: 0, Advanced: 0, Expert: 0, Exceptional: 0 };
+ const skillCounts = {};
+
+ const normalizedStudents = students.map((s, index) => {
+ const overall = s.scores?.overall || 0;
+ const dev = s.scores?.development || 0;
+ const ps = s.scores?.problemSolving || 0;
+ const know = s.scores?.knowledge || 0;
+
+ totalScore += overall;
+ totalDev += dev;
+ totalProblem += ps;
+ totalKnowledge += know;
+
+ if (s.placementReadinessScore) totalReadiness += s.placementReadinessScore;
+ if (s.profileCompletion) totalProfileComp += s.profileCompletion;
+
+ if (s.status === 'active' || s.accountStatus === 'ACTIVE') activeStudents++;
+ if (s.isVerified || s.accountStatus === 'ACTIVE') verifiedStudents++;
+
+ if (s.platforms?.github?.username) githubLinkedCount++;
+ if (s.platforms?.leetcode?.username) leetcodeLinkedCount++;
+
+ const tier = calculateScoreTier(overall);
+ tierDistribution[tier] = (tierDistribution[tier] || 0) + 1;
+
+ (s.skillsList || []).forEach((sk) => {
+ const name = typeof sk === 'string' ? sk : sk?.name;
+ if (name) skillCounts[name] = (skillCounts[name] || 0) + 1;
+ });
+
+ return {
+ rank: index + 1,
+ id: s._id,
+ name: s.name || 'Unnamed Student',
+ email: s.email || 'N/A',
+ maviId: s.maviId || `MAVI-${s._id.toString().slice(-8).toUpperCase()}`,
+ prn: s.prn || 'Pending',
+ status: s.status || 'active',
+ accountStatus: s.accountStatus || 'ACTIVE',
+ isVerified: Boolean(s.isVerified),
+ scores: {
+ development: dev,
+ problemSolving: ps,
+ knowledge: know,
+ overall: overall,
+ },
+ tier,
+ platforms: {
+ github: s.platforms?.github?.username || null,
+ leetcode: s.platforms?.leetcode?.username || null,
+ },
+ placementStatus: s.placementStatus || 'Available for Hiring',
+ placementReadinessScore: s.placementReadinessScore || 0,
+ profileCompletion: s.profileCompletion || 0,
+ };
+ });
+
+ const divisor = totalStudents || 1;
+ const topSkills = Object.entries(skillCounts)
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 8);
+
+ const summary = {
+ totalStudents,
+ activeStudents,
+ verifiedStudents,
+ averageScores: {
+ overall: Math.round(totalScore / divisor),
+ development: Math.round(totalDev / divisor),
+ problemSolving: Math.round(totalProblem / divisor),
+ knowledge: Math.round(totalKnowledge / divisor),
+ },
+ averagePlacementReadiness: Math.round(totalReadiness / divisor),
+ averageProfileCompletion: Math.round(totalProfileComp / divisor),
+ platformStats: {
+ githubLinked: githubLinkedCount,
+ leetcodeLinked: leetcodeLinkedCount,
+ },
+ tierDistribution,
+ topSkills,
+ };
+
+ return {
+ institutionName,
+ departmentName,
+ departmentCode,
+ generatedAt: new Date().toISOString(),
+ totalRecords: totalStudents,
+ reportType: 'DEPARTMENT_STUDENT_PERFORMANCE',
+ summary,
+ students: normalizedStudents,
+ };
+};
+
+/**
+ * Draw a horizontal progress bar in PDFKit
+ */
+const drawProgressBar = (doc, x, y, width, height, current, max = 1000, color = '#4f46e5', label = '') => {
+ const validMax = max > 0 ? max : 1000;
+ const ratio = Math.min(Math.max(current / validMax, 0), 1);
+ const fillWidth = Math.max(width * ratio, 2);
+
+ // Background
+ doc.rect(x, y, width, height).fill('#e2e8f0');
+
+ // Fill
+ if (fillWidth > 0) {
+ doc.rect(x, y, fillWidth, height).fill(color);
+ }
+
+ // Label & score text
+ doc.fillColor('#334155').fontSize(8).font('Helvetica-Bold');
+ doc.text(label, x, y - 11);
+ doc.fillColor('#0f172a').fontSize(8).font('Helvetica');
+ doc.text(`${current}/${max}`, x + width - 50, y - 11, { width: 50, align: 'right' });
+};
+
+/**
+ * Generate and stream Department Performance Report PDF
+ */
+const writeDepartmentReportPdf = async (reportData, res) => {
+ const cleanDept = (reportData.departmentName || 'Department')
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
+ .replace(/_+/g, '_');
+ const dateStr = new Date().toISOString().split('T')[0];
+ const fileName = `MAVI_Department_Performance_Report_${cleanDept}_${dateStr}.pdf`;
+
+ res.status(200);
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
+ res.setHeader('Cache-Control', 'no-store');
+
+ const doc = new PDFDocument({
+ size: 'A4',
+ margin: 40,
+ bufferPages: true,
+ });
+
+ doc.pipe(res);
+
+ const pageWidth = doc.page.width;
+ const pageHeight = doc.page.height;
+ const contentWidth = pageWidth - 80;
+
+ // ─── HEADER SECTION ────────────────────────────────────────────────────────
+ // Primary brand banner background
+ doc.rect(40, 40, contentWidth, 68).fill('#0f172a');
+
+ // Brand title
+ doc.fillColor('#818cf8').fontSize(11).font('Helvetica-Bold').text('MAVI LINKING', 55, 52, { characterSpacing: 1.5 });
+ doc.fillColor('#ffffff').fontSize(16).font('Helvetica-Bold').text('Department Performance Report', 55, 68);
+ doc.fillColor('#94a3b8').fontSize(8).font('Helvetica').text('Official Department-Scoped Analytics & Student Assessment Record', 55, 88);
+
+ // Metadata Card
+ const metaY = 118;
+ doc.rect(40, metaY, contentWidth, 54).fillAndStroke('#f8fafc', '#e2e8f0');
+
+ // Left column: Institution & Department
+ doc.fillColor('#64748b').fontSize(8).font('Helvetica-Bold').text('INSTITUTION:', 55, metaY + 10);
+ doc.fillColor('#0f172a').fontSize(9).font('Helvetica').text(reportData.institutionName || 'N/A', 125, metaY + 10, { width: 170 });
+
+ doc.fillColor('#64748b').fontSize(8).font('Helvetica-Bold').text('DEPARTMENT:', 55, metaY + 28);
+ doc.fillColor('#0f172a').fontSize(9).font('Helvetica-Bold').text(reportData.departmentName || 'N/A', 125, metaY + 28, { width: 170 });
+
+ // Right column: Generated Date & Total Records
+ doc.fillColor('#64748b').fontSize(8).font('Helvetica-Bold').text('GENERATED:', 320, metaY + 10);
+ doc.fillColor('#0f172a').fontSize(8.5).font('Helvetica').text(formatDateTime(reportData.generatedAt), 395, metaY + 10, { width: 150 });
+
+ doc.fillColor('#64748b').fontSize(8).font('Helvetica-Bold').text('TOTAL RECORDS:', 320, metaY + 28);
+ doc.fillColor('#4f46e5').fontSize(9.5).font('Helvetica-Bold').text(`${reportData.totalRecords} Students`, 405, metaY + 27);
+
+ // ─── KPI SUMMARY OVERVIEW ──────────────────────────────────────────────────
+ const kpiY = 182;
+ doc.fillColor('#0f172a').fontSize(11).font('Helvetica-Bold').text('Department Overview & Key Metrics', 40, kpiY);
+
+ const cardWidth = (contentWidth - 15) / 4;
+ const cardHeight = 44;
+ const kpiTop = kpiY + 16;
+
+ const kpis = [
+ { label: 'TOTAL STUDENTS', value: `${reportData.summary.totalStudents}`, color: '#4f46e5' },
+ { label: 'ACTIVE STUDENTS', value: `${reportData.summary.activeStudents}`, color: '#059669' },
+ { label: 'AVG MAVI SCORE', value: `${reportData.summary.averageScores.overall} pts`, color: '#7c3aed' },
+ { label: 'AVG DEV SCORE', value: `${reportData.summary.averageScores.development} pts`, color: '#0284c7' },
+ ];
+
+ kpis.forEach((kpi, idx) => {
+ const cardX = 40 + idx * (cardWidth + 5);
+ doc.rect(cardX, kpiTop, cardWidth, cardHeight).fillAndStroke('#f8fafc', '#e2e8f0');
+ doc.fillColor('#64748b').fontSize(7).font('Helvetica-Bold').text(kpi.label, cardX + 8, kpiTop + 8);
+ doc.fillColor(kpi.color).fontSize(13).font('Helvetica-Bold').text(kpi.value, cardX + 8, kpiTop + 22);
+ });
+
+ // ─── SCORE BENCHMARKS & VISUALIZATION ──────────────────────────────────────
+ const vizY = kpiTop + cardHeight + 14;
+ doc.rect(40, vizY, contentWidth, 54).fillAndStroke('#f8fafc', '#e2e8f0');
+
+ const barW = (contentWidth - 40) / 3;
+ const barY = vizY + 28;
+
+ drawProgressBar(doc, 55, barY, barW, 8, reportData.summary.averageScores.development, 1000, '#0284c7', 'Average Development');
+ drawProgressBar(doc, 55 + barW + 15, barY, barW, 8, reportData.summary.averageScores.problemSolving, 1000, '#059669', 'Average Problem Solving');
+ drawProgressBar(doc, 55 + (barW + 15) * 2, barY, barW, 8, reportData.summary.averageScores.overall, 1000, '#7c3aed', 'Average Overall MAVI');
+
+ // ─── STUDENT PERFORMANCE TABLE ─────────────────────────────────────────────
+ let tableStartY = vizY + 68;
+ doc.fillColor('#0f172a').fontSize(11).font('Helvetica-Bold').text('Student Performance Breakdown', 40, tableStartY);
+ tableStartY += 14;
+
+ const drawTableHeader = (yPos) => {
+ doc.rect(40, yPos, contentWidth, 20).fill('#0f172a');
+ doc.fillColor('#ffffff').fontSize(8).font('Helvetica-Bold');
+ doc.text('#', 45, yPos + 6, { width: 20 });
+ doc.text('Student Name & Email', 70, yPos + 6, { width: 145 });
+ doc.text('MAVI ID / PRN', 220, yPos + 6, { width: 115 });
+ doc.text('Dev', 340, yPos + 6, { width: 35, align: 'right' });
+ doc.text('Problem', 380, yPos + 6, { width: 45, align: 'right' });
+ doc.text('Overall', 430, yPos + 6, { width: 40, align: 'right' });
+ doc.text('Status', 480, yPos + 6, { width: 65, align: 'center' });
+ return yPos + 20;
+ };
+
+ let currentY = drawTableHeader(tableStartY);
+
+ if (reportData.students.length === 0) {
+ // Empty state card
+ doc.rect(40, currentY, contentWidth, 60).fillAndStroke('#ffffff', '#e2e8f0');
+ doc.fillColor('#64748b').fontSize(10).font('Helvetica').text(
+ 'No performance records are currently available for this department.',
+ 40,
+ currentY + 24,
+ { width: contentWidth, align: 'center' }
+ );
+ } else {
+ reportData.students.forEach((std, idx) => {
+ const rowHeight = 26;
+
+ // Check if row exceeds page height boundary
+ if (currentY + rowHeight > pageHeight - 55) {
+ doc.addPage();
+ currentY = drawTableHeader(40);
+ }
+
+ // Alternating row background
+ const bgColor = idx % 2 === 0 ? '#ffffff' : '#f8fafc';
+ doc.rect(40, currentY, contentWidth, rowHeight).fillAndStroke(bgColor, '#f1f5f9');
+
+ // Rank
+ doc.fillColor('#64748b').fontSize(8).font('Helvetica-Bold').text(`${std.rank}`, 45, currentY + 8, { width: 20 });
+
+ // Student Name & Email
+ doc.fillColor('#0f172a').fontSize(8).font('Helvetica-Bold').text(std.name, 70, currentY + 4, { width: 145, ellipsis: true });
+ doc.fillColor('#64748b').fontSize(7).font('Helvetica').text(std.email, 70, currentY + 14, { width: 145, ellipsis: true });
+
+ // MAVI ID / PRN
+ doc.fillColor('#4f46e5').fontSize(7.5).font('Helvetica-Bold').text(std.maviId, 220, currentY + 4, { width: 115, ellipsis: true });
+ doc.fillColor('#0284c7').fontSize(7).font('Helvetica').text(std.prn ? `PRN: ${std.prn}` : 'PRN: Pending', 220, currentY + 14, { width: 115, ellipsis: true });
+
+ // Scores
+ doc.fillColor('#0284c7').fontSize(8).font('Helvetica-Bold').text(`${std.scores.development}`, 340, currentY + 8, { width: 35, align: 'right' });
+ doc.fillColor('#059669').fontSize(8).font('Helvetica-Bold').text(`${std.scores.problemSolving}`, 380, currentY + 8, { width: 45, align: 'right' });
+ doc.fillColor('#7c3aed').fontSize(8.5).font('Helvetica-Bold').text(`${std.scores.overall}`, 430, currentY + 8, { width: 40, align: 'right' });
+
+ // Status pill text
+ const statusText = (std.status || 'Active').toUpperCase();
+ doc.fillColor(std.status === 'active' || std.accountStatus === 'ACTIVE' ? '#059669' : '#e11d48')
+ .fontSize(7)
+ .font('Helvetica-Bold')
+ .text(statusText, 480, currentY + 8, { width: 65, align: 'center' });
+
+ currentY += rowHeight;
+ });
+ }
+
+ // ─── FOOTER (ON ALL PAGES) ─────────────────────────────────────────────────
+ const pages = doc.bufferedPageRange();
+ for (let i = 0; i < pages.count; i++) {
+ doc.switchToPage(i);
+ const footerY = pageHeight - 30;
+
+ // Divider line
+ doc.moveTo(40, footerY - 6).lineTo(pageWidth - 40, footerY - 6).strokeColor('#e2e8f0').stroke();
+
+ // Footer text
+ doc.fillColor('#64748b').fontSize(7.5).font('Helvetica');
+ doc.text('MAVI Linking — Department Performance Report', 40, footerY, { width: 220, align: 'left' });
+ doc.text(`Generated: ${formatDateTime(reportData.generatedAt)}`, 220, footerY, { width: 160, align: 'center' });
+ doc.text(`Page ${i + 1} of ${pages.count}`, pageWidth - 140, footerY, { width: 100, align: 'right' });
+ }
+
+ doc.end();
+};
+
+module.exports = {
+ generateDepartmentReportData,
+ writeDepartmentReportPdf,
+};
diff --git a/server/src/services/githubActivityService.js b/server/src/services/githubActivityService.js
index f7c0b27..23d65d2 100644
--- a/server/src/services/githubActivityService.js
+++ b/server/src/services/githubActivityService.js
@@ -129,19 +129,23 @@ const syncGitHubActivities = async (userId) => {
const activities = [];
for (const event of payload) {
- const mapped = mapGitHubEvent(event, userId);
-
- const exists = await Activity.findOne({
- userId,
- platform: 'github',
- date: mapped.date,
- title: mapped.title,
- url: mapped.url,
- });
-
- if (!exists) {
- const activity = await Activity.create(mapped);
- activities.push(activity);
+ try {
+ const mapped = mapGitHubEvent(event, userId);
+
+ const exists = await Activity.findOne({
+ userId,
+ platform: 'github',
+ date: mapped.date,
+ title: mapped.title,
+ url: mapped.url,
+ });
+
+ if (!exists) {
+ const activity = await Activity.create(mapped);
+ activities.push(activity);
+ }
+ } catch (actErr) {
+ console.warn('[GitHub Activity Sync] Activity record insertion warning:', actErr.message);
}
}
diff --git a/server/src/services/githubIntelligenceService.js b/server/src/services/githubIntelligenceService.js
index bf2d754..3bfbf25 100644
--- a/server/src/services/githubIntelligenceService.js
+++ b/server/src/services/githubIntelligenceService.js
@@ -1,47 +1,53 @@
/**
* GitHub Developer Intelligence Normalizer & Analytics Engine
* Converts raw GitHub API data into structured, explainable intelligence metrics.
+ * Provides a canonical single source of truth for developer identity.
*/
-const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], username = '') => {
- const canonicalUsername = rawProfile?.login || username;
+const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], username = '', previousData = null, syncMeta = {}) => {
+ const canonicalUsername = (rawProfile?.login || username || previousData?.profile?.username || '').trim();
- // 1. Profile Intelligence
+ // 1. Profile Intelligence (Validate numeric and string fields)
const profile = {
username: canonicalUsername,
- name: rawProfile?.name || null,
- avatarUrl: rawProfile?.avatar_url || null,
- bio: rawProfile?.bio || null,
- company: rawProfile?.company || null,
- location: rawProfile?.location || null,
+ name: rawProfile?.name || previousData?.profile?.name || null,
+ avatarUrl: rawProfile?.avatar_url || previousData?.profile?.avatarUrl || null,
+ bio: rawProfile?.bio || previousData?.profile?.bio || null,
+ company: rawProfile?.company || previousData?.profile?.company || null,
+ location: rawProfile?.location || previousData?.profile?.location || null,
profileUrl: rawProfile?.html_url || `https://github.com/${canonicalUsername}`,
- followers: rawProfile?.followers || 0,
- following: rawProfile?.following || 0,
- publicRepos: rawProfile?.public_repos || rawRepos.length || 0,
- accountCreatedAt: rawProfile?.created_at || null,
- accountAgeYears: rawProfile?.created_at
- ? Math.max(0, Math.round((Date.now() - new Date(rawProfile.created_at).getTime()) / (365.25 * 86400000) * 10) / 10)
+ followers: Math.max(0, typeof rawProfile?.followers === 'number' ? rawProfile.followers : (previousData?.profile?.followers || 0)),
+ following: Math.max(0, typeof rawProfile?.following === 'number' ? rawProfile.following : (previousData?.profile?.following || 0)),
+ publicRepos: Math.max(0, typeof rawProfile?.public_repos === 'number' ? rawProfile.public_repos : (rawRepos.length || previousData?.profile?.publicRepos || 0)),
+ accountCreatedAt: rawProfile?.created_at || previousData?.profile?.accountCreatedAt || null,
+ accountAgeYears: (rawProfile?.created_at || previousData?.profile?.accountCreatedAt)
+ ? Math.max(0, Math.round((Date.now() - new Date(rawProfile?.created_at || previousData?.profile?.accountCreatedAt).getTime()) / (365.25 * 86400000) * 10) / 10)
: null,
};
- // 2. Repository Intelligence
- const repositories = rawRepos.map((r) => ({
- name: r.name,
- fullName: r.full_name || `${canonicalUsername}/${r.name}`,
- description: r.description || '',
- url: r.html_url || `https://github.com/${canonicalUsername}/${r.name}`,
- owner: r.owner?.login || canonicalUsername,
- isFork: Boolean(r.fork),
- language: r.language || 'Unspecified',
- topics: Array.isArray(r.topics) ? r.topics : [],
- stars: r.stargazers_count || 0,
- forks: r.forks_count || 0,
- openIssues: r.open_issues_count || 0,
- createdAt: r.created_at,
- updatedAt: r.updated_at,
- isArchived: Boolean(r.archived),
- defaultBranch: r.default_branch || 'main',
- }));
+ // 2. Repository Intelligence (Use newly fetched if available, fallback to previous)
+ let repositories = [];
+ if (Array.isArray(rawRepos) && rawRepos.length > 0) {
+ repositories = rawRepos.map((r) => ({
+ name: r.name,
+ fullName: r.full_name || `${canonicalUsername}/${r.name}`,
+ description: r.description || '',
+ url: r.html_url || `https://github.com/${canonicalUsername}/${r.name}`,
+ owner: r.owner?.login || canonicalUsername,
+ isFork: Boolean(r.fork),
+ language: r.language || 'Unspecified',
+ topics: Array.isArray(r.topics) ? r.topics : [],
+ stars: Math.max(0, r.stargazers_count || 0),
+ forks: Math.max(0, r.forks_count || 0),
+ openIssues: Math.max(0, r.open_issues_count || 0),
+ createdAt: r.created_at || null,
+ updatedAt: r.updated_at || null,
+ isArchived: Boolean(r.archived),
+ defaultBranch: r.default_branch || 'main',
+ }));
+ } else if (previousData?.repositories && previousData.repositories.length > 0) {
+ repositories = previousData.repositories;
+ }
// 3. Language Intelligence (Dynamic Repository Language Distribution)
const languageCounts = {};
@@ -94,7 +100,11 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
let externalPRCount = 0;
let externalIssueCount = 0;
- rawEvents.forEach((ev) => {
+ const eventsToProcess = Array.isArray(rawEvents) && rawEvents.length > 0
+ ? rawEvents
+ : [];
+
+ eventsToProcess.forEach((ev) => {
const eventType = ev.type || 'Other';
eventCountsByType[eventType] = (eventCountsByType[eventType] || 0) + 1;
@@ -104,7 +114,7 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
const repoFullName = ev.repo?.name || '';
const repoOwner = repoFullName.split('/')[0]?.toLowerCase();
- const isExternal = repoOwner && repoOwner !== canonicalUsername.toLowerCase();
+ const isExternal = repoOwner && canonicalUsername && repoOwner !== canonicalUsername.toLowerCase();
if (isExternal) {
externalReposSet.add(repoFullName);
@@ -171,10 +181,11 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
// 5. Commit Intelligence
const commits = {
+ available: eventsToProcess.length > 0,
recentCount30Days: recentCommitCount,
activeRepositoriesCount: Object.keys(commitsByRepo).length,
commitsByRepo,
- status: rawEvents.length > 0 ? 'active' : 'no_recent_events',
+ status: eventsToProcess.length > 0 ? 'active' : 'no_recent_events',
};
// 6. Pull Request Intelligence
@@ -182,16 +193,18 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
const mergeRate = totalCompletedPRs > 0 ? Math.round((prsMerged / totalCompletedPRs) * 100) : null;
const pullRequests = {
+ available: eventsToProcess.length > 0,
opened: prsOpened,
closed: prsClosed,
merged: prsMerged,
mergeRate: mergeRate !== null ? `${mergeRate}%` : 'Insufficient data',
externalPRs: externalPRCount,
- status: rawEvents.length > 0 ? 'available' : 'insufficient_data',
+ status: eventsToProcess.length > 0 ? 'available' : 'insufficient_data',
};
// 7. Open Source Intelligence
const openSource = {
+ available: eventsToProcess.length > 0,
externalReposContributed: externalReposSet.size,
externalReposList: Array.from(externalReposSet),
externalPRs: externalPRCount,
@@ -201,19 +214,23 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
// 8. Reviews & Collaboration
const reviews = {
+ available: eventsToProcess.length > 0,
submitted: reviewsSubmitted,
status: reviewsSubmitted > 0 ? 'active' : 'none_recorded',
};
// 9. Issues Intelligence
const issues = {
+ available: eventsToProcess.length > 0,
created: issuesCreated,
closed: issuesClosed,
+ externalIssues: externalIssueCount,
status: (issuesCreated + issuesClosed) > 0 ? 'active' : 'none_recorded',
};
// 10. Software Delivery & Releases
const releases = {
+ available: eventsToProcess.length > 0,
count: releaseCount,
latestRelease,
status: releaseCount > 0 ? 'active' : 'none_recorded',
@@ -221,10 +238,11 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
// 11. Contributions Summary
const contributions = {
- totalRecentEvents: rawEvents.length,
+ available: eventsToProcess.length > 0,
+ totalRecentEvents: eventsToProcess.length,
dailyStreak,
activeDaysRecorded: activeDaysSet.size,
- status: rawEvents.length > 0 ? 'available' : 'unavailable',
+ status: eventsToProcess.length > 0 ? 'available' : 'unavailable',
};
return {
@@ -240,13 +258,17 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [],
releases,
activity: {
eventCountsByType,
- lastActiveAt: rawEvents[0]?.created_at || null,
+ lastActiveAt: eventsToProcess[0]?.created_at || previousData?.activity?.lastActiveAt || null,
consistencyScore: Math.min(activeDaysSet.size * 10, 100),
},
sync: {
- lastSyncedAt: new Date(),
- status: 'complete',
- error: null,
+ status: syncMeta.status || 'complete',
+ lastSyncedAt: syncMeta.completedAt || new Date(),
+ startedAt: syncMeta.startedAt || new Date(),
+ completedAt: syncMeta.completedAt || new Date(),
+ durationMs: syncMeta.durationMs || 0,
+ error: syncMeta.error || null,
+ source: 'github_rest_api',
},
};
};
diff --git a/server/src/services/githubService.js b/server/src/services/githubService.js
index 54d7d7f..eba8104 100644
--- a/server/src/services/githubService.js
+++ b/server/src/services/githubService.js
@@ -72,30 +72,30 @@ const fetchUserProfile = async (username) => {
};
/**
- * Fetch up to 30 most recently updated public repositories.
+ * Fetch up to 100 most recently updated public repositories.
*/
-const fetchUserRepositories = async (username, maxCount = 30) => {
+const fetchUserRepositories = async (username, maxCount = 100) => {
try {
- const url = `https://api.github.com/users/${encodeURIComponent(username)}/repos?per_page=${maxCount}&sort=updated&type=all`;
+ const url = `https://api.github.com/users/${encodeURIComponent(username)}/repos?per_page=${Math.min(maxCount, 100)}&sort=updated&type=all`;
const { payload } = await safeGitHubFetch(url);
return Array.isArray(payload) ? payload : [];
} catch (err) {
console.warn(`[GitHub API] Failed to fetch repositories for ${username}:`, err.message);
- return [];
+ throw err;
}
};
/**
- * Fetch up to 50 public events (push, PR, issue, release).
+ * Fetch up to 100 public events (push, PR, issue, release).
*/
-const fetchUserEvents = async (username, maxCount = 50) => {
+const fetchUserEvents = async (username, maxCount = 100) => {
try {
- const url = `https://api.github.com/users/${encodeURIComponent(username)}/events/public?per_page=${maxCount}`;
+ const url = `https://api.github.com/users/${encodeURIComponent(username)}/events/public?per_page=${Math.min(maxCount, 100)}`;
const { payload } = await safeGitHubFetch(url);
return Array.isArray(payload) ? payload : [];
} catch (err) {
console.warn(`[GitHub API] Failed to fetch public events for ${username}:`, err.message);
- return [];
+ throw err;
}
};
diff --git a/server/src/services/githubSyncService.js b/server/src/services/githubSyncService.js
index 74c179a..f5ffaa1 100644
--- a/server/src/services/githubSyncService.js
+++ b/server/src/services/githubSyncService.js
@@ -104,50 +104,92 @@ const syncGitHubAccount = async (userId, customUsername = null) => {
}
syncLocks.set(userId.toString(), true);
+ const startedAt = new Date();
+ console.log(`[GitHub Sync] Started synchronization for user ${userId} (@${username}) at ${startedAt.toISOString()}`);
try {
+ const previousGithubData = user.platformData?.github || null;
+
// 1. Fetch from GitHub API with graceful partial handling
let rawProfile = null;
let rawRepos = [];
let rawEvents = [];
- let fetchError = null;
+ const partialErrors = [];
try {
rawProfile = await fetchUserProfile(username);
} catch (err) {
- fetchError = err.message;
+ console.error(`[GitHub Sync] Failed to fetch profile for @${username}:`, err.message);
+ // If profile fails, preserve existing data and mark failed sync
+ const durationMs = Date.now() - startedAt.getTime();
+ const failedSyncMeta = {
+ status: 'failed',
+ startedAt,
+ completedAt: new Date(),
+ durationMs,
+ error: err.message,
+ };
+ if (previousGithubData) {
+ user.platformData.github.sync = failedSyncMeta;
+ await user.save();
+ }
throw new Error(`Unable to fetch GitHub profile for "${username}": ${err.message}`);
}
try {
- rawRepos = await fetchUserRepositories(username, 30);
+ rawRepos = await fetchUserRepositories(username, 100);
} catch (err) {
- console.warn(`[GitHub Sync] Repos fetch warning for ${username}:`, err.message);
+ console.warn(`[GitHub Sync] Repos fetch warning for @${username}:`, err.message);
+ partialErrors.push(`Repositories: ${err.message}`);
}
try {
- rawEvents = await fetchUserEvents(username, 50);
+ rawEvents = await fetchUserEvents(username, 100);
} catch (err) {
- console.warn(`[GitHub Sync] Events fetch warning for ${username}:`, err.message);
+ console.warn(`[GitHub Sync] Events fetch warning for @${username}:`, err.message);
+ partialErrors.push(`Events: ${err.message}`);
}
- // 2. Normalize Intelligence Data
- const githubData = normalizeGitHubIntelligence(rawProfile, rawRepos, rawEvents, username);
+ const completedAt = new Date();
+ const durationMs = completedAt.getTime() - startedAt.getTime();
+ const syncStatus = partialErrors.length > 0 ? 'partial' : 'complete';
+
+ const syncMeta = {
+ status: syncStatus,
+ startedAt,
+ completedAt,
+ durationMs,
+ error: partialErrors.length > 0 ? partialErrors.join(' | ') : null,
+ };
+
+ // 2. Normalize Intelligence Data (merging with previous data if partial)
+ const githubData = normalizeGitHubIntelligence(
+ rawProfile,
+ rawRepos,
+ rawEvents,
+ username,
+ previousGithubData,
+ syncMeta
+ );
// 3. Persist Activities into Activity Collection (deduplicated)
if (rawEvents.length > 0) {
for (const event of rawEvents) {
- const mapped = mapGitHubEventToActivity(event, user._id);
- const exists = await Activity.findOne({
- userId: user._id,
- platform: 'github',
- date: mapped.date,
- title: mapped.title,
- url: mapped.url,
- });
-
- if (!exists) {
- await Activity.create(mapped);
+ try {
+ const mapped = mapGitHubEventToActivity(event, user._id);
+ const exists = await Activity.findOne({
+ userId: user._id,
+ platform: 'github',
+ date: mapped.date,
+ title: mapped.title,
+ url: mapped.url,
+ });
+
+ if (!exists) {
+ await Activity.create(mapped);
+ }
+ } catch (actErr) {
+ console.warn('[GitHub Sync] Activity record insertion warning:', actErr.message);
}
}
}
@@ -163,59 +205,18 @@ const syncGitHubAccount = async (userId, customUsername = null) => {
user.githubUsername = username; // Legacy mirror for backwards compatibility
user.platformData = user.platformData || {};
-await ExternalIdentity.findOneAndUpdate(
- {
- userId: user._id,
- platform: 'github',
- verificationStatus: 'verified',
- },
- {
- $set: {
- lastSuccessfulSync: new Date(),
- },
- }
-); user.lastSyncedAt = new Date();
-
- await user.save();
- // 4.5 Record immutable activity events for this sync (idempotent per syncVersion)
- const { recordEvent } = require('./activityEventService');
- const syncVersion = user.lastSyncedAt.toISOString();
- const newRepoCount = githubData?.profile?.publicRepos ?? 0;
- const prevRepoCount = previousGithubData?.profile?.publicRepos ?? null;
- if (prevRepoCount === null || prevRepoCount !== newRepoCount) {
- await recordEvent({
- userId: user._id,
- platform: 'github',
- eventType: 'REPOSITORY_CHANGE',
- previousValue: { publicRepos: prevRepoCount },
- newValue: { publicRepos: newRepoCount },
- syncVersion,
- });
- }
- const newContributions = githubData?.contributions?.totalRecentEvents ?? 0;
- const prevContributions = previousGithubData?.contributions?.totalRecentEvents ?? null;
- if (prevContributions === null || prevContributions !== newContributions) {
- await recordEvent({
- userId: user._id,
- platform: 'github',
- eventType: 'CONTRIBUTION_CHANGE',
- previousValue: { totalRecentEvents: prevContributions },
- newValue: { totalRecentEvents: newContributions },
- syncVersion,
- });
- }
-const {
- recordSyncSuccess,
- recordSyncFailure,
-} = require('./syncConsistencyService');
- // 5. Trigger Canonical Intelligence & Scoring Evaluation const { evaluateUserIntelligence } = require('./careerIntelligenceService');
+
const updatedUser = await evaluateUserIntelligence(user._id);
return {
success: true,
+ status: syncStatus,
+ durationMs,
data: githubData,
user: updatedUser,
- message: 'GitHub intelligence synchronized successfully.',
+ message: syncStatus === 'partial'
+ ? 'GitHub synchronized partially (some endpoints unavailable).'
+ : 'GitHub intelligence synchronized successfully.',
};
} catch (error) {
try {
diff --git a/server/src/utils/sendEmail.js b/server/src/utils/sendEmail.js
index 1863183..e4feb30 100644
--- a/server/src/utils/sendEmail.js
+++ b/server/src/utils/sendEmail.js
@@ -145,7 +145,7 @@ const generatePasswordResetEmailHtml = ({ name, otp, resetLink }) => {