diff --git a/client/src/components/GitHubIntelligenceCard.jsx b/client/src/components/GitHubIntelligenceCard.jsx new file mode 100644 index 0000000..4710be6 --- /dev/null +++ b/client/src/components/GitHubIntelligenceCard.jsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect, useCallback, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + GitBranch, + ArrowRight, + Code2, + FolderGit2, + Activity as ActivityIcon, + Check, + Link as LinkIcon, +} from 'lucide-react'; +import api from '../api/axios'; +import GitHubIntelligenceModal from './GitHubIntelligenceModal'; +import { AuthContext } from '../context/AuthContext'; + +const GitHubIntelligenceCard = ({ externalScores = null }) => { + const navigate = useNavigate(); + const { user } = useContext(AuthContext); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + + const fetchIntelligence = useCallback(async () => { + try { + setLoading(true); + const res = await api.get('/platforms/github/intelligence'); + if (res.data?.success) { + setData(res.data.data); + } + } catch (err) { + console.warn('Failed to load GitHub intelligence summary:', err.message); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchIntelligence(); + }, [fetchIntelligence]); + + const isLinked = Boolean(data?.linked && (data?.username || user?.githubUsername)); + const username = data?.username || user?.githubUsername || ''; + const intelligence = data?.intelligence || {}; + const repositoriesCount = intelligence?.repositories?.length ?? intelligence?.profile?.publicRepos ?? 0; + const languageDistribution = intelligence?.languages?.distribution || {}; + const languagesCount = Object.keys(languageDistribution).length; + + let activityStatus = 'Active'; + if (intelligence?.commits?.recentCount30Days !== undefined && intelligence?.commits?.recentCount30Days !== null) { + activityStatus = intelligence.commits.recentCount30Days > 0 + ? `${intelligence.commits.recentCount30Days} commits` + : 'Active'; + } else if (intelligence?.contributions?.level) { + activityStatus = intelligence.contributions.level; + } + + const devScore = data?.totalScore ?? data?.scores?.development ?? externalScores?.development ?? user?.scores?.development ?? 0; + const scorePercent = Math.min(100, Math.max(0, Math.round((devScore / 1000) * 100))); + + const formatLastSynced = () => { + if (!data?.lastSyncedAt) return null; + const minutes = data.freshnessMinutes !== undefined ? data.freshnessMinutes : Math.floor((Date.now() - new Date(data.lastSyncedAt).getTime()) / 60000); + if (minutes < 1) return 'Synced just now'; + if (minutes < 60) return `Synced ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Synced ${hours}h ago`; + return `Synced ${new Date(data.lastSyncedAt).toLocaleDateString()}`; + }; + + const syncLabel = formatLastSynced(); + + return ( + <> +
+ {/* Compact Header */} +
+
+
+ +

+ GitHub Intelligence +

+
+ {isLinked && ( +
+ + @{username} + + {syncLabel && ( + + • {syncLabel} + + )} +
+ )} +
+ + {isLinked ? ( + + Connected + + ) : ( + + Not Connected + + )} +
+ + {/* Card Content */} + {loading ? ( +
+
+
+
+ ) : !isLinked ? ( +
+

+ Link GitHub to track repos, commits, and compute your development score. +

+ +
+ ) : ( +
+ {/* Compact 3-Column Statistics */} +
+
+
+ Repos +
+
+ {repositoriesCount} +
+
+ +
+
+ Languages +
+
+ {languagesCount} +
+
+ +
+
+ Activity +
+
+ {activityStatus} +
+
+
+ + {/* Development Score Row */} +
+
+ + Development Score + + + {devScore} / 1000 + +
+
+
+
+
+ + {/* Compact Action Button */} + +
+ )} +
+ + {/* Existing Detailed Modal */} + setModalOpen(false)} + username={username} + onSyncSuccess={fetchIntelligence} + /> + + ); +}; + +export default GitHubIntelligenceCard; diff --git a/client/src/components/GitHubIntelligenceModal.jsx b/client/src/components/GitHubIntelligenceModal.jsx index 8da58c7..616e6f6 100644 --- a/client/src/components/GitHubIntelligenceModal.jsx +++ b/client/src/components/GitHubIntelligenceModal.jsx @@ -24,7 +24,7 @@ import { import api from '../api/axios'; import { useToast } from '../context/ToastContext'; -const GitHubIntelligenceModal = ({ isOpen, onClose, username }) => { +const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) => { const toast = useToast(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -53,7 +53,12 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username }) => { const res = await api.post('/platforms/github/sync'); if (res.data?.success) { toast.success(res.data.message || 'GitHub intelligence updated!'); - fetchIntelligence(); + if (res.data.data) { + setData(res.data.data); + } else { + fetchIntelligence(); + } + if (onSyncSuccess) onSyncSuccess(); } } catch (err) { console.error('Sync failed:', err); @@ -193,8 +198,16 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username }) => { {data?.lastSyncedAt && ( - - • Synced {new Date(data.lastSyncedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + • {data.syncStatus === 'partial' ? '⚠ Partial Sync' : 'Synced'} {new Date(data.lastSyncedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} )}
@@ -212,10 +225,12 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username }) => { alignItems: 'center', gap: '0.4rem', padding: '0.4rem 0.75rem', + borderColor: syncing ? 'var(--accent-purple)' : undefined, }} + aria-label="Synchronize GitHub intelligence data" > - {syncing ? 'Syncing...' : 'Sync GitHub'} + {syncing ? 'Syncing GitHub data...' : 'Sync GitHub'} - )} - -
+
) : (
{ const [platforms, setPlatforms] = useState(null); const [loading, setLoading] = useState(true); - const [intelModalOpen, setIntelModalOpen] = useState(false); const toast = useToast(); const fetchPlatforms = async () => { @@ -133,17 +120,10 @@ const AccountLinking = () => { info={platforms[key]} onLink={handleLink} onUnlink={handleUnlink} - onOpenIntelligence={() => setIntelModalOpen(true)} /> ))} )} - - setIntelModalOpen(false)} - username={platforms?.github?.username} - /> ); }; diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index cc6a24e..fcb4370 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -28,6 +28,7 @@ import DNACard from '../components/DNACard'; import GrowthChart from '../components/GrowthChart'; import LeaderboardWidget from '../components/LeaderboardWidget'; import CareerRoadmapCard from '../components/CareerRoadmapCard'; +import GitHubIntelligenceCard from '../components/GitHubIntelligenceCard'; import SkillRadar from '../components/SkillRadar'; import TimelineWidget from '../components/TimelineWidget'; import LeetCodeSection from '../components/leetcode/LeetCodeSection'; @@ -886,7 +887,10 @@ const Dashboard = () => {
- +
+ + +
diff --git a/client/src/pages/VerifyAccount.jsx b/client/src/pages/VerifyAccount.jsx index 20b8dd1..1d115c7 100644 --- a/client/src/pages/VerifyAccount.jsx +++ b/client/src/pages/VerifyAccount.jsx @@ -357,7 +357,7 @@ const VerifyAccount = () => { > - Account activation links expire in 24 hours for your security. Check your inbox and spam folder. + Account activation links expire in 10 minutes for your security. Check your inbox and spam folder. diff --git a/client/src/pages/admin/AdminAcceptInvite.jsx b/client/src/pages/admin/AdminAcceptInvite.jsx index 5a7f8bf..1e43656 100644 --- a/client/src/pages/admin/AdminAcceptInvite.jsx +++ b/client/src/pages/admin/AdminAcceptInvite.jsx @@ -329,7 +329,7 @@ const AdminAcceptInvite = () => { )}
- ⏱️ This invitation is valid for 24 hours. + ⏱️ This invitation is valid for 10 minutes. {inviteData.expiresAt && ( Expires: {new Date(inviteData.expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}, {new Date(inviteData.expiresAt).toLocaleDateString()} diff --git a/client/src/pages/department/DepartmentAdminDashboard.jsx b/client/src/pages/department/DepartmentAdminDashboard.jsx index 93594ae..d08140f 100644 --- a/client/src/pages/department/DepartmentAdminDashboard.jsx +++ b/client/src/pages/department/DepartmentAdminDashboard.jsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Shield, Users, GraduationCap, FileText, Megaphone, Search, AlertCircle, BarChart3, Trophy, Download, LogOut } from 'lucide-react'; +import { Shield, Users, GraduationCap, FileText, Megaphone, Search, AlertCircle, BarChart3, Trophy, Download, LogOut, Loader2 } from 'lucide-react'; import api from '../../api/axios'; import { AuthContext } from '../../context/AuthContext'; +import { notify } from '../../context/ToastContext'; const DepartmentAdminDashboard = () => { const { user, logout } = useContext(AuthContext); @@ -14,6 +15,7 @@ const DepartmentAdminDashboard = () => { const [leaderboard, setLeaderboard] = useState([]); const [reportsData, setReportsData] = useState(null); const [loading, setLoading] = useState(true); + const [generatingPdf, setGeneratingPdf] = useState(false); const [activeTab, setActiveTab] = useState('overview'); // 'overview' | 'students' | 'teachers' | 'analytics' | 'leaderboard' | 'reports' const [search, setSearch] = useState(''); @@ -22,6 +24,42 @@ const DepartmentAdminDashboard = () => { navigate('/admin/login', { replace: true }); }; + const handleDownloadPdf = async () => { + if (generatingPdf) return; + setGeneratingPdf(true); + try { + const response = await api.get('/department-admin/reports/pdf', { + responseType: 'blob', + }); + + let filename = `MAVI_Department_Performance_Report_${(dashboardData?.departmentName || user?.university?.department || 'Department').replace(/[^a-zA-Z0-9_-]/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf`; + const disposition = response.headers?.['content-disposition']; + if (disposition && disposition.includes('filename=')) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + + const blob = new Blob([response.data], { type: 'application/pdf' }); + const url = window.URL.createObjectURL(blob); + const downloadAnchor = document.createElement('a'); + downloadAnchor.href = url; + downloadAnchor.setAttribute('download', filename); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + window.URL.revokeObjectURL(url); + + notify('success', 'PDF generated successfully.'); + } catch (err) { + console.error('Error generating PDF report:', err); + notify('error', 'Unable to generate the PDF report. Please try again.'); + } finally { + setGeneratingPdf(false); + } + }; + useEffect(() => { const fetchData = async () => { setLoading(true); @@ -386,28 +424,63 @@ const DepartmentAdminDashboard = () => { {/* Reports View */} {activeTab === 'reports' && (
-
+

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 }) => {
- Security Notice: This OTP code and reset link are strictly valid for 15 minutes. If you did not request this password reset, please ignore this email or contact security. + Security Notice: This OTP code and reset link are strictly valid for 10 minutes. If you did not request this password reset, please ignore this email or contact security.
- Security Notice: No default password is sent in plain text. You will create your confidential password directly on the activation portal. This single-use link is valid for ${expiresHours} hours. Please complete your account setup before the invitation expires. + Security Notice: No default password is sent in plain text. You will create your confidential password directly on the activation portal. This single-use link is valid for ${validityText}. Please complete your account setup before the invitation expires.
- Security Notice: This code expires in 15 minutes. If you did not initiate this email change request, please contact security immediately. + Security Notice: This code expires in 10 minutes. If you did not initiate this email change request, please contact security immediately.
- Security Notice: This verification link is valid for ${expiresHours} hours and can only be used once. If you did not register for a MAVI Linking account, please disregard this message. + Security Notice: This verification link is valid for 10 minutes and can only be used once. If you did not register for a MAVI Linking account, please disregard this message.

- Your MAVI Linking administrator invitation is valid for ${expiresHours} hours. Please complete your account setup before the invitation expires. + Your MAVI Linking administrator invitation is valid for ${validityText}. Please complete your account setup before the invitation expires.

@@ -540,7 +543,7 @@ const generateAdminInvitationEmailHtml = ({
- Security Notice: No plain-text passwords are ever sent via email. You will securely configure your credentials upon accepting the invitation. This single-use link is valid for ${expiresHours} hours. + Security Notice: No plain-text passwords are ever sent via email. You will securely configure your credentials upon accepting the invitation. This single-use link is valid for ${validityText}.