From d268920a07c652ca08901d69dd6d7cc54fcb2e97 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:44:46 +0530 Subject: [PATCH 01/36] feat(tpo): add college-specific email role pattern configuration --- backend/controllers/collegeController.js | 60 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/backend/controllers/collegeController.js b/backend/controllers/collegeController.js index 660fa6a5..3d24671d 100644 --- a/backend/controllers/collegeController.js +++ b/backend/controllers/collegeController.js @@ -15,6 +15,7 @@ import User from "../models/User.js"; import { logger } from "../config/logger.js"; import { recordAdminAction } from "../services/adminAuditLog.js"; import { looksLikeEmailAddress } from "../utils/collegeNameHeuristics.js"; +import { sanitizeRolePatternRules } from "../services/tpoRoleSignalService.js"; // ── GET /api/admin/colleges ────────────────────────────────────────────────── // Lists every college (not just pending ones — that's getPendingQueue's job) @@ -86,6 +87,8 @@ export async function getColleges(req, res) { return { id: college._id, name: college.name, + staffEmailPatterns: college.staffEmailPatterns || [], + studentEmailPatterns: college.studentEmailPatterns || [], domains: college.domains, website: college.website, status: college.status, @@ -172,4 +175,59 @@ export async function renameCollege(req, res) { logger.error({ err }, "[Admin] rename college error"); return res.status(500).json({ error: "Failed to rename college." }); } -} \ No newline at end of file +} + +/** + * PATCH /api/admin/colleges/:collegeId/email-role-patterns + * + * College-specific email patterns are advisory evidence only. They help + * classify an authenticated institutional email as staff_candidate, + * student_candidate, ambiguous, or unknown; they never grant TPO access. + */ +export async function updateEmailRolePatterns(req, res) { + try { + const { collegeId } = req.params; + const { staffEmailPatterns, studentEmailPatterns } = req.body || {}; + + if (!Array.isArray(staffEmailPatterns) || !Array.isArray(studentEmailPatterns)) { + return res.status(400).json({ + error: "staffEmailPatterns and studentEmailPatterns must both be arrays.", + }); + } + + const staff = sanitizeRolePatternRules(staffEmailPatterns); + const students = sanitizeRolePatternRules(studentEmailPatterns); + + const college = await College.findById(collegeId); + if (!college) return res.status(404).json({ error: "College not found." }); + + college.staffEmailPatterns = staff; + college.studentEmailPatterns = students; + await college.save(); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "college.email_role_patterns.update", + targetType: "College", + targetId: college._id, + details: { + staffRuleCount: staff.length, + studentRuleCount: students.length, + }, + }); + + return res.json({ + success: true, + college: { + id: college._id, + name: college.name, + domains: college.domains, + staffEmailPatterns: college.staffEmailPatterns, + studentEmailPatterns: college.studentEmailPatterns, + }, + }); + } catch (err) { + logger.error({ err }, "[Admin] update college email-role patterns error"); + return res.status(500).json({ error: "Failed to update college email-role patterns." }); + } +} From 5e35490417c0d43c991816eb603ff5dab4f60e85 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:44:51 +0530 Subject: [PATCH 02/36] feat(tpo): expose college email role pattern admin endpoint --- backend/routes/admin.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 3e561128..80a40002 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -20,7 +20,7 @@ import { startImpersonation, stopImpersonation, } from "../controllers/adminController.js"; -import { getColleges, renameCollege } from "../controllers/collegeController.js"; +import { getColleges, renameCollege, updateEmailRolePatterns } from "../controllers/collegeController.js"; import { listProblemsForAdmin, getProblemForAdmin, @@ -116,6 +116,7 @@ router.get("/users", requireAdmin, listUsers); // ── Colleges ───────────────────────────────────────────────────────────────── router.get("/colleges", requireAdmin, getColleges); router.patch("/colleges/:collegeId", requireAdmin, renameCollege); +router.patch("/colleges/:collegeId/email-role-patterns", requireAdmin, updateEmailRolePatterns); // NOTE: order matters here. "/impersonate/stop" must be registered before // the parameterized "/impersonate/:userId" — Express matches routes in // registration order, and :userId matches the literal segment "stop" too. From 3cab91322ce857336c77f49b3f68fb8501d49e7d Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:44:59 +0530 Subject: [PATCH 03/36] test(tpo): cover college email role pattern configuration --- backend/controllers/collegeController.test.js | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/controllers/collegeController.test.js b/backend/controllers/collegeController.test.js index 2740ca99..92430f45 100644 --- a/backend/controllers/collegeController.test.js +++ b/backend/controllers/collegeController.test.js @@ -1,18 +1,25 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("../models/College.js", () => ({ - default: { find: vi.fn(), countDocuments: vi.fn() }, + default: { find: vi.fn(), countDocuments: vi.fn(), findById: vi.fn() }, })); vi.mock("../models/User.js", () => ({ default: { countDocuments: vi.fn(), aggregate: vi.fn() }, })); +vi.mock("../services/tpoRoleSignalService.js", () => ({ + sanitizeRolePatternRules: vi.fn((rules) => rules.map((r) => ({ ...r }))), +})); +vi.mock("../services/adminAuditLog.js", () => ({ + recordAdminAction: vi.fn(), +})); vi.mock("../config/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); import College from "../models/College.js"; import User from "../models/User.js"; -import { getColleges } from "./collegeController.js"; +import { getColleges, updateEmailRolePatterns } from "./collegeController.js"; +import { sanitizeRolePatternRules } from "../services/tpoRoleSignalService.js"; function chainableQuery(result) { const q = { @@ -176,4 +183,45 @@ describe("collegeController", () => { expect(res.status).toHaveBeenCalledWith(500); }); }); -}); \ No newline at end of file +}); +describe("updateEmailRolePatterns", () => { + it("sanitizes and persists staff/student rules separately", async () => { + const college = { + _id: "c1", + name: "MIT", + domains: ["mit.edu"], + staffEmailPatterns: [], + studentEmailPatterns: [], + save: vi.fn().mockResolvedValue(undefined), + }; + College.findById.mockResolvedValueOnce(college); + sanitizeRolePatternRules + .mockReturnValueOnce([{ type: "domain", value: "staff.mit.edu" }]) + .mockReturnValueOnce([{ type: "domain", value: "students.mit.edu" }]); + + await updateEmailRolePatterns( + { + params: { collegeId: "c1" }, + body: { + staffEmailPatterns: [{ type: "domain", value: "staff.mit.edu" }], + studentEmailPatterns: [{ type: "domain", value: "students.mit.edu" }], + }, + userDoc: { _id: "admin1" }, + }, + res + ); + + expect(college.staffEmailPatterns).toEqual([{ type: "domain", value: "staff.mit.edu" }]); + expect(college.studentEmailPatterns).toEqual([{ type: "domain", value: "students.mit.edu" }]); + expect(college.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true })); + }); + + it("rejects missing rule arrays", async () => { + await updateEmailRolePatterns( + { params: { collegeId: "c1" }, body: { staffEmailPatterns: [] } }, + res + ); + expect(res.status).toHaveBeenCalledWith(400); + }); +}); From e638701359b9752bed943f23dd4b8c4e58cb7186 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:45:20 +0530 Subject: [PATCH 04/36] feat(tpo): add college email role pattern editor --- .../admin/CollegeEmailRoleRules.jsx | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/components/admin/CollegeEmailRoleRules.jsx diff --git a/src/components/admin/CollegeEmailRoleRules.jsx b/src/components/admin/CollegeEmailRoleRules.jsx new file mode 100644 index 00000000..b8450744 --- /dev/null +++ b/src/components/admin/CollegeEmailRoleRules.jsx @@ -0,0 +1,163 @@ +import { useEffect, useMemo, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import Button from "../ui/Button"; + +const EMPTY_RULE = { type: "domain", value: "" }; + +function normalizeRule(rule) { + if (rule.type === "local_prefix") { + return { type: "local_prefix", values: Array.isArray(rule.values) ? rule.values : [] }; + } + return { type: rule.type || "domain", value: rule.value || "" }; +} + +export default function CollegeEmailRoleRules({ staffRules, studentRules, onSave }) { + const [activeRole, setActiveRole] = useState("staff"); + const [rules, setRules] = useState({ staff: [], student: [] }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setRules({ + staff: (staffRules || []).map(normalizeRule), + student: (studentRules || []).map(normalizeRule), + }); + }, [staffRules, studentRules]); + + const activeRules = rules[activeRole]; + const summary = useMemo( + () => ({ staff: rules.staff.length, student: rules.student.length }), + [rules] + ); + + function updateRule(index, patch) { + setRules((current) => ({ + ...current, + [activeRole]: current[activeRole].map((rule, i) => + i === index ? { ...rule, ...patch } : rule + ), + })); + } + + function updatePrefixValues(index, value) { + updateRule(index, { + values: value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean), + }); + } + + function addRule() { + setRules((current) => ({ + ...current, + [activeRole]: [...current[activeRole], { ...EMPTY_RULE }], + })); + } + + function removeRule(index) { + setRules((current) => ({ + ...current, + [activeRole]: current[activeRole].filter((_, i) => i !== index), + })); + } + + async function save() { + setSaving(true); + try { + await onSave(rules.staff, rules.student); + } finally { + setSaving(false); + } + } + + return ( +
+
+ These patterns are advisory evidence. They help Code Club distinguish likely + staff/TPO mailboxes from student mailboxes, but they never grant TPO access. +
+ +
+ {[ + ["staff", "Staff / TPO"], + ["student", "Students"], + ].map(([id, label]) => ( + + ))} +
+ +
+ {activeRules.map((rule, index) => ( +
+
+ + + {rule.type === "domain" ? "Exact email domain" : + rule.type === "local_prefix" ? "Before @" : "Before @ regex"} + + +
+ + {rule.type === "local_prefix" ? ( + updatePrefixValues(index, e.target.value)} + placeholder="student., 22, ug." + className="w-full bg-[var(--surface)] border border-[var(--border)] rounded-md px-2.5 py-1.5 text-xs text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]" + /> + ) : ( + updateRule(index, { value: e.target.value })} + placeholder={rule.type === "domain" ? "students.example.edu" : "^(dr\\.|prof)"} + className="w-full bg-[var(--surface)] border border-[var(--border)] rounded-md px-2.5 py-1.5 text-xs text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]" + /> + )} +
+ ))} +
+ +
+ + +
+
+ ); +} From aa886b5d9e3660f2f6b10916d1c6d3d8133e948c Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:45:27 +0530 Subject: [PATCH 05/36] feat(tpo): wire college email role pattern updates --- src/hooks/useAdminColleges.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/hooks/useAdminColleges.js b/src/hooks/useAdminColleges.js index 0f533d92..555cfcc4 100644 --- a/src/hooks/useAdminColleges.js +++ b/src/hooks/useAdminColleges.js @@ -74,6 +74,25 @@ export function useAdminColleges() { // corrected name. Falls back to a toast + re-throw so the calling UI // (CollegeDetailDrawer) can keep its edit form open on failure instead // of silently losing the user's edit. + async function updateEmailRolePatterns(collegeId, staffEmailPatterns, studentEmailPatterns) { + const data = await apiFetch(`/api/admin/colleges/${collegeId}/email-role-patterns`, { + method: "PATCH", + body: JSON.stringify({ staffEmailPatterns, studentEmailPatterns }), + }); + setColleges((prev) => + prev.map((c) => + c.id === collegeId + ? { + ...c, + staffEmailPatterns: data.college.staffEmailPatterns, + studentEmailPatterns: data.college.studentEmailPatterns, + } + : c + ) + ); + return data.college; + } + async function renameCollege(collegeId, name) { const data = await apiFetch(`/api/admin/colleges/${collegeId}`, { method: "PATCH", @@ -96,5 +115,6 @@ export function useAdminColleges() { searchInput, setSearchInput, renameCollege, + updateEmailRolePatterns, }; } \ No newline at end of file From 08f6823a466178b06520ea4f7a5350469ec3acba Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:45:33 +0530 Subject: [PATCH 06/36] feat(tpo): add college email role pattern controls --- src/components/admin/CollegeDetailDrawer.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/admin/CollegeDetailDrawer.jsx b/src/components/admin/CollegeDetailDrawer.jsx index 66460d54..e0c1975c 100644 --- a/src/components/admin/CollegeDetailDrawer.jsx +++ b/src/components/admin/CollegeDetailDrawer.jsx @@ -3,6 +3,7 @@ import { Users2, ExternalLink, Pencil, Check, X as XIcon, Sparkles } from "lucid import toast from "react-hot-toast"; import Button from "../ui/Button"; import SideDrawer, { DrawerSection, DrawerField } from "./command/SideDrawer"; +import CollegeEmailRoleRules from "./CollegeEmailRoleRules"; const STATUS_STYLES = { pending: "bg-amber-500/10 text-amber-400", @@ -23,7 +24,7 @@ const STATUS_STYLES = { * cse.nits.ac.in) — this is the correction UI for that guess, and for any * other college whose name just needs fixing. */ -export default function CollegeDetailDrawer({ college, open, onClose, onViewStudents, onRename }) { +export default function CollegeDetailDrawer({ college, open, onClose, onViewStudents, onRename, onSaveEmailRolePatterns }) { const [editing, setEditing] = useState(false); const [nameInput, setNameInput] = useState(""); const [saving, setSaving] = useState(false); @@ -137,6 +138,16 @@ export default function CollegeDetailDrawer({ college, open, onClose, onViewStud + + + onSaveEmailRolePatterns(college.id, staffRules, studentRules) + } + /> + + From adfed6c0503515888ecc2951d53c0869a715dae1 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:45:38 +0530 Subject: [PATCH 07/36] feat(tpo): expose email role configuration in college admin --- src/pages/admin/AdminCollegesPage.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/admin/AdminCollegesPage.jsx b/src/pages/admin/AdminCollegesPage.jsx index 3013f389..f3ec3f39 100644 --- a/src/pages/admin/AdminCollegesPage.jsx +++ b/src/pages/admin/AdminCollegesPage.jsx @@ -53,6 +53,7 @@ export default function AdminCollegesPage() { searchInput, setSearchInput, renameCollege, + updateEmailRolePatterns, } = useAdminColleges(); const selectedCollege = colleges.find((c) => c.id === selectedCollegeId) || null; @@ -212,6 +213,7 @@ export default function AdminCollegesPage() { onClose={() => setSelectedCollegeId(null)} onViewStudents={viewStudents} onRename={renameCollege} + onSaveEmailRolePatterns={updateEmailRolePatterns} /> ); From c0a26c9a40c403b47575e7b4225e54bcb6ccd16f Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:18 +0530 Subject: [PATCH 08/36] fix(tpo): separate college trust from individual TPO verification --- backend/routes/tpo.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index 7e2615f0..2a8f5d13 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -117,7 +117,7 @@ router.post("/register", async (req, res) => { }); } - const autoVerified = + const collegeAutoVerified = (existingCollege?.status === "verified" && !existingIsAutoPlaceholder) || (await isDomainAutoVerified(domain, "college")); @@ -130,8 +130,8 @@ router.post("/register", async (req, res) => { collegeDoc = await College.create({ domains: [domain], name: collegeName.trim(), - status: autoVerified ? "verified" : "pending", - verifiedAt: autoVerified ? now : null, + status: collegeAutoVerified ? "verified" : "pending", + verifiedAt: collegeAutoVerified ? now : null, submittedBy: req.userDoc._id, submittedByRole: "tpo", }); @@ -204,7 +204,7 @@ router.post("/register", async (req, res) => { } let isPrimary = false; - if (autoVerified && collegeDoc) { + if (collegeAutoVerified && collegeDoc) { try { isPrimary = await claimPrimaryIfNone(collegeDoc._id, req.userDoc._id); } catch (err) { @@ -218,17 +218,21 @@ router.post("/register", async (req, res) => { return res.status(201).json({ success: true, role: "tpo", - verified: autoVerified, - status: autoVerified ? "verified" : "pending", + // A verified college does not automatically make the requester a + // verified TPO. The reviewer must approve the individual TPO request. + verified: false, + status: "pending", isPrimary, emailRoleSignal: emailRoleClassification, verification: { - status: autoVerified ? "approved" : "pending", + // Individual TPO authorization always requires human verification. + // College/domain trust and TPO role verification are separate facts. + status: "pending", additionalEvidenceRecommended: emailRoleClassification !== "staff_candidate", }, - message: autoVerified - ? "Your college is verified. You're all set — head to your dashboard." + message: collegeAutoVerified + ? "Your college is recognized. Your TPO access request is now waiting for individual verification." : "Your college registration request has been submitted for verification.", }); } catch (err) { From 7f1e6ac9f0d46a5975e246bfbbb17d82b70cde39 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:29 +0530 Subject: [PATCH 09/36] feat(tpo): add individual TPO verification decisions --- backend/controllers/adminController.js | 148 ++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 8b3b8e18..5a86c6eb 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -1044,4 +1044,150 @@ export async function stopImpersonation(req, res) { logger.error({ err }, "[Admin] impersonate stop error"); return res.status(500).json({ error: "Failed to stop impersonation." }); } -} \ No newline at end of file +} + +async function resolvePendingTpoCollege(user) { + const domain = user?.tpoProfile?.collegeDomain; + if (!domain) return null; + return College.findByDomain(domain); +} + +export async function approveTpoUser(req, res) { + try { + const user = await User.findById(req.params.userId); + if (!user || user.role !== "tpo") { + return res.status(404).json({ error: "TPO verification request not found." }); + } + if (user.tpoProfile?.verified) { + return res.json({ success: true, alreadyVerified: true }); + } + + const college = await resolvePendingTpoCollege(user); + if (!college || college.status !== "verified") { + return res.status(409).json({ + error: "The TPO's college must be verified before individual TPO access can be approved.", + }); + } + + const now = new Date(); + user.tpoProfile.verified = true; + user.tpoProfile.verifiedAt = now; + user.tpoVerification = { + ...(user.tpoVerification || {}), + status: "approved", + }; + await user.save(); + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + try { + await TpoVerificationReview.create({ + userId: user._id, + collegeId: college._id, + requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", + emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", + evidence: user.tpoVerification?.evidence || [], + decision: "approved", + decisionReason: "Approved through individual TPO verification.", + reviewedBy: req.actingAdminDoc?._id || req.userDoc?._id, + reviewedAt: now, + }); + } catch (err) { + logger.error({ err, userId: user._id }, "[Admin] approveTpoUser: audit write failed"); + } + + try { + await claimPrimaryIfNone(college._id, user._id); + } catch (err) { + logger.error({ err, collegeId: college._id, userId: user._id }, "[Admin] approveTpoUser primary claim failed"); + } + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.user.approve", + targetType: "User", + targetId: user._id, + details: { collegeId: college._id }, + }); + + createNotification({ + userId: user._id, + type: "tpo_verified", + title: "TPO access approved", + message: college.name + " TPO access is now verified.", + link: "/tpo/dashboard", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] approve TPO user error"); + return res.status(500).json({ error: "Failed to approve TPO verification." }); + } +} + +export async function rejectTpoUser(req, res) { + try { + const user = await User.findById(req.params.userId); + if (!user || user.role !== "tpo") { + return res.status(404).json({ error: "TPO verification request not found." }); + } + + const college = await resolvePendingTpoCollege(user); + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + const now = new Date(); + + if (reviewerId && college) { + try { + await TpoVerificationReview.create({ + userId: user._id, + collegeId: college._id, + requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", + emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", + evidence: user.tpoVerification?.evidence || [], + decision: "rejected", + decisionReason: "Individual TPO verification request rejected.", + reviewedBy: reviewerId, + reviewedAt: now, + }); + } catch (err) { + logger.error({ err, userId: user._id }, "[Admin] rejectTpoUser: audit write failed"); + } + } + + user.revokeRole("tpo"); + user.role = "student"; + user.tpoProfile = { + collegeDomain: null, + collegeName: null, + verified: false, + requestedAt: null, + verifiedAt: null, + }; + user.tpoVerification = { + ...(user.tpoVerification || {}), + status: "rejected", + }; + await user.save(); + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.user.reject", + targetType: "User", + targetId: user._id, + details: { collegeId: college?._id || null }, + }); + + createNotification({ + userId: user._id, + type: "tpo_rejected", + title: "TPO access request declined", + message: "We couldn't verify your TPO access request. Reach out if this was a mistake.", + link: "/tpo/signup", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] reject TPO user error"); + return res.status(500).json({ error: "Failed to reject TPO verification." }); + } +} From af91359e738e40d72634bef7d373a95062cb4a8e Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:35 +0530 Subject: [PATCH 10/36] feat(tpo): expose individual TPO verification routes --- backend/routes/admin.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 80a40002..b8d2c834 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -7,6 +7,8 @@ import { rejectRecruiter, approveTpo, rejectTpo, + approveTpoUser, + rejectTpoUser, approveStudentCollege, rejectStudentCollege, listUsers, @@ -107,6 +109,8 @@ router.post("/recruiters/:id/approve", requireAdmin, approveRecruiter); router.post("/recruiters/:id/reject", requireAdmin, rejectRecruiter); router.post("/tpo/:collegeId/approve", requireAdmin, approveTpo); router.post("/tpo/:collegeId/reject", requireAdmin, rejectTpo); +router.post("/tpo-verification/:userId/approve", requireAdmin, approveTpoUser); +router.post("/tpo-verification/:userId/reject", requireAdmin, rejectTpoUser); router.post("/student-colleges/:collegeId/approve", requireAdmin, approveStudentCollege); router.post("/student-colleges/:collegeId/reject", requireAdmin, rejectStudentCollege); From c91df08a03c63558207f7d0cf36086d6bbbe8527 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:43 +0530 Subject: [PATCH 11/36] feat(tpo): surface individual pending TPO verification requests --- backend/controllers/adminController.js | 1112 +----------------------- 1 file changed, 40 insertions(+), 1072 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 5a86c6eb..09c3c836 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -42,7 +42,7 @@ const RECRUITER_QUEUE_FIELDS = "email displayName recruiterProfile createdAt"; // ── GET /api/admin/pending ────────────────────────────────────────────────── export async function getPendingQueue(req, res) { try { - const [recruiters, pendingColleges] = await Promise.all([ + const [recruiters, pendingColleges, pendingTpoUsers] = await Promise.all([ User.find( { role: "recruiter", "recruiterProfile.verified": false }, RECRUITER_QUEUE_FIELDS @@ -57,6 +57,16 @@ export async function getPendingQueue(req, res) { .populate("submittedBy", "email displayName tpoVerification") .sort({ createdAt: 1 }) .lean(), + User.find( + { + role: "tpo", + "tpoProfile.verified": false, + "tpoVerification.status": "pending", + }, + "email displayName tpoProfile tpoVerification createdAt" + ) + .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) + .lean(), ]); const tpoColleges = pendingColleges.filter((c) => c.submittedByRole === "tpo"); @@ -66,6 +76,13 @@ export async function getPendingQueue(req, res) { // submitted an "auto" record, so requestedBy will just be null for // those — the frontend labels them "Auto-detected" instead of a // requester name (see AdminOverviewPage.jsx). + const pendingCollegeRequesterIds = new Set( + tpoColleges.map((c) => c.submittedBy?._id?.toString()).filter(Boolean) + ); + const individualTpoRequests = pendingTpoUsers.filter( + (u) => !pendingCollegeRequesterIds.has(u._id.toString()) + ); + const studentColleges = pendingColleges.filter( (c) => c.submittedByRole === "student" || c.submittedByRole === "auto" ); @@ -80,7 +97,8 @@ export async function getPendingQueue(req, res) { companyDomain: u.recruiterProfile?.companyDomain, requestedAt: u.createdAt, })), - tpos: tpoColleges.map((c) => { + tpos: [ + ...tpoColleges.map((c) => { const applicant = c.submittedBy && typeof c.submittedBy === "object" ? c.submittedBy : null; @@ -101,7 +119,25 @@ export async function getPendingQueue(req, res) { additionalEvidenceRecommended: signal !== "staff_candidate", evidence: applicant?.tpoVerification?.evidence || [], }; - }), + }), + ...individualTpoRequests.map((u) => { + const signal = u.tpoVerification?.emailRoleSignal || "unknown"; + return { + userId: u._id, + collegeId: null, + collegeName: u.tpoProfile?.collegeName || "Unknown college", + domain: u.tpoProfile?.collegeDomain, + domains: u.tpoProfile?.collegeDomain ? [u.tpoProfile.collegeDomain] : [], + requestedBy: { email: u.email, displayName: u.displayName }, + requestedAt: u.tpoVerification?.submittedAt || u.tpoProfile?.requestedAt || u.createdAt, + emailRoleSignal: signal, + verificationStatus: u.tpoVerification?.status || "pending", + additionalEvidenceRecommended: signal !== "staff_candidate", + evidence: u.tpoVerification?.evidence || [], + reviewTarget: "user", + }; + }), + ], studentCollegeRequests: studentColleges.map((c) => ({ collegeId: c._id, collegeName: c.name, @@ -122,1072 +158,4 @@ export async function getPendingQueue(req, res) { // ── POST /api/admin/recruiters/:id/approve ────────────────────────────────── export async function approveRecruiter(req, res) { - try { - const user = await User.findById(req.params.id); - - if (!user || user.role !== "recruiter") { - return res.status(404).json({ error: "Recruiter not found." }); - } - - user.recruiterProfile.verified = true; - user.recruiterProfile.verifiedAt = new Date(); - await user.save(); - // This user's own requireAuth cache entry (on whichever instance they - // next hit) would otherwise still show `verified: false` for up to - // AUTH_USER_CACHE_TTL_MS — drop it on this instance now so a request - // that happens to land here sees the fresh doc immediately. - invalidateCachedUserByFirebaseUid(user.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "recruiter.approve", - targetType: "User", - targetId: user._id, - }); - - createNotification({ - userId: user._id, - type: "recruiter_verified", - title: "Recruiter access approved", - message: `You're verified for ${user.recruiterProfile.companyName}. Your dashboard is ready.`, - link: "/recruiter/dashboard", - }).catch(() => {}); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] approve recruiter error"); - return res.status(500).json({ error: "Failed to approve recruiter." }); - } -} - -// ── POST /api/admin/recruiters/:id/reject ─────────────────────────────────── -export async function rejectRecruiter(req, res) { - try { - const user = await User.findById(req.params.id); - - if (!user || user.role !== "recruiter") { - return res.status(404).json({ error: "Recruiter not found." }); - } - - const companyName = user.recruiterProfile?.companyName; - - // Revoke the "recruiter" authorization (not just the active role) so - // this account can no longer switch back into a recruiter session via - // POST /me/switch-role — matches the additive grantRole() at - // registration. Falls active role back to "student", which every - // account is authorized for by default. - user.revokeRole("recruiter"); - user.role = "student"; - user.recruiterProfile = { - companyName: null, - designation: null, - companyDomain: null, - verified: false, - verifiedAt: null, - }; - await user.save(); - invalidateCachedUserByFirebaseUid(user.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "recruiter.reject", - targetType: "User", - targetId: user._id, - }); - - createNotification({ - userId: user._id, - type: "recruiter_rejected", - title: "Recruiter access request declined", - message: companyName - ? `We couldn't verify your request for ${companyName}. Reach out if this was a mistake.` - : "We couldn't verify your recruiter access request.", - link: "/recruiter/signup", - }).catch(() => {}); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] reject recruiter error"); - return res.status(500).json({ error: "Failed to reject recruiter." }); - } -} - -// Shared by approveTpo/rejectTpo and approveStudentCollege/rejectStudentCollege -// — flips the institution's own trust state. Callers are responsible for -// whatever role-specific follow-up (tpoProfile sync, education.collegeStatus -// sync) their submitter type needs. -async function setCollegeStatus(collegeId, status) { - const college = await College.findById(collegeId); - if (!college) return null; - college.status = status; - college.verifiedAt = status === "verified" ? new Date() : null; - await college.save(); - return college; -} - -// ── POST /api/admin/tpo/:collegeId/approve ────────────────────────────────── -export async function approveTpo(req, res) { - try { - const college = await setCollegeStatus(req.params.collegeId, "verified"); - if (!college) return res.status(404).json({ error: "College request not found." }); - - const pendingCandidates = await User.find({ - role: "tpo", - "tpoProfile.collegeDomain": { $in: college.domains }, - "tpoProfile.verified": false, - }) - .sort({ "tpoProfile.requestedAt": 1, _id: 1 }) - .select("_id firebaseUid email tpoVerification") - .lean(); - - await User.updateMany( - { role: "tpo", "tpoProfile.collegeDomain": { $in: college.domains }, "tpoProfile.verified": false }, - { - $set: { - "tpoProfile.verified": true, - "tpoProfile.verifiedAt": college.verifiedAt, - "tpoVerification.status": "approved", - }, - } - ); - - pendingCandidates.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); - - if (pendingCandidates.length > 0) { - try { - await claimPrimaryIfNone(college._id, pendingCandidates[0]._id); - } catch (err) { - logger.error({ err, collegeId: college._id }, "[Admin] approveTpo primary claim failed"); - } - } - - const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; - if (reviewerId) { - try { - await Promise.all( - pendingCandidates.map((u) => - TpoVerificationReview.create({ - userId: u._id, - collegeId: college._id, - requestedEmail: u.tpoVerification?.submittedEmail || u.email || "", - emailRoleSignal: u.tpoVerification?.emailRoleSignal || "unknown", - evidence: u.tpoVerification?.evidence || [], - decision: "approved", - decisionReason: "Approved through the administrative TPO verification queue.", - reviewedBy: reviewerId, - reviewedAt: college.verifiedAt || new Date(), - }) - ) - ); - } catch (err) { - logger.error( - { err, collegeId: college._id }, - "[Admin] approveTpo: failed to persist verification review audit" - ); - } - } - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.approve", - targetType: "College", - targetId: college._id, - }); - - if (college.submittedBy) { - createNotification({ - userId: college.submittedBy, - type: "tpo_verified", - title: "TPO access approved", - message: college.name + " is verified. Your placement dashboard is ready.", - link: "/tpo/dashboard", - }).catch(() => {}); - } - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] approve TPO error"); - return res.status(500).json({ error: "Failed to approve TPO." }); - } -} - - -// ── POST /api/admin/tpo/:collegeId/reject ─────────────────────────────────── -export async function rejectTpo(req, res) { - try { - const college = await College.findById(req.params.collegeId); - if (!college) return res.status(404).json({ error: "College request not found." }); - - const requesterId = college.submittedBy; - const collegeName = college.name; - const requester = requesterId - ? await User.findById(requesterId) - : null; - - const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; - if (reviewerId && requester) { - try { - await TpoVerificationReview.create({ - userId: requester._id, - collegeId: college._id, - requestedEmail: requester.tpoVerification?.submittedEmail || requester.email || "", - emailRoleSignal: requester.tpoVerification?.emailRoleSignal || "unknown", - evidence: requester.tpoVerification?.evidence || [], - decision: "rejected", - decisionReason: "TPO request rejected through the administrative verification queue.", - reviewedBy: reviewerId, - reviewedAt: new Date(), - }); - } catch (err) { - logger.error( - { err, collegeId: college._id }, - "[Admin] rejectTpo: failed to persist verification review audit" - ); - } - } - - await College.deleteOne({ _id: college._id }); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.reject", - targetType: "College", - targetId: college._id, - }); - - if (requester && requester.role === "tpo") { - requester.revokeRole("tpo"); - requester.role = "student"; - requester.tpoProfile = { - collegeDomain: null, - collegeName: null, - verified: false, - requestedAt: null, - verifiedAt: null, - }; - requester.tpoVerification = { - ...(requester.tpoVerification || {}), - status: "rejected", - emailRoleSignal: requester.tpoVerification?.emailRoleSignal || "unknown", - submittedEmail: requester.tpoVerification?.submittedEmail || requester.email || null, - submittedAt: requester.tpoVerification?.submittedAt || null, - evidence: requester.tpoVerification?.evidence || [], - }; - await requester.save(); - invalidateCachedUserByFirebaseUid(requester.firebaseUid); - - createNotification({ - userId: requesterId, - type: "tpo_rejected", - title: "TPO access request declined", - message: "We couldn't verify your request for " + collegeName + ". Reach out if this was a mistake.", - link: "/tpo/signup", - }).catch(() => {}); - } - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] reject TPO error"); - return res.status(500).json({ error: "Failed to reject TPO request." }); - } -} - - -// ── POST /api/admin/student-colleges/:collegeId/approve ──────────────────── -// Approves a college that was requested via a student's college-email -// verification (backend/routes/collegeVerification.js), as opposed to a TPO -// registration. Pushes the new status to every user whose education is -// linked to this college and has already verified their email — mirrors the -// tpoProfile-sync pattern in approveTpo above, applied to `education`. -export async function approveStudentCollege(req, res) { - try { - const college = await setCollegeStatus(req.params.collegeId, "verified"); - if (!college) { - return res.status(404).json({ error: "College request not found." }); - } - - const affected = await User.find({ - "education.collegeId": college._id, - "education.emailVerified": true, - }); - - await Promise.all( - affected.map((u) => { - u.education.collegeStatus = "verified"; - return u.save(); - }) - ); - - affected.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "studentCollege.approve", - targetType: "College", - targetId: college._id, - }); - - affected.forEach((u) => - createNotification({ - userId: u._id, - type: "college_verified", - title: "Your college is now verified", - message: `${college.name} has been added to Code Club's verified colleges. Your College Leaderboard is unlocked.`, - link: "/club/leaderboard", - }).catch(() => {}) - ); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] approve student college error"); - return res.status(500).json({ error: "Failed to approve college." }); - } -} - -// ── POST /api/admin/student-colleges/:collegeId/reject ────────────────────── -// Unlike rejectTpo, this does NOT delete the College doc — the record is -// kept with status:"rejected" so a resubmission for the same domain is -// recognized as "already reviewed" (see the 409 check in -// collegeVerification.js's findOrCreatePendingCollege) rather than silently -// re-queuing a previously-rejected institution. -export async function rejectStudentCollege(req, res) { - try { - const college = await setCollegeStatus(req.params.collegeId, "rejected"); - if (!college) { - return res.status(404).json({ error: "College request not found." }); - } - - const affected = await User.find({ - "education.collegeId": college._id, - "education.emailVerified": true, - }); - - await Promise.all( - affected.map((u) => { - u.education.collegeStatus = "rejected"; - return u.save(); - }) - ); - - affected.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "studentCollege.reject", - targetType: "College", - targetId: college._id, - }); - - affected.forEach((u) => - createNotification({ - userId: u._id, - type: "college_rejected", - title: "College verification update", - message: `We weren't able to verify ${college.name} for official College Leaderboard status. Your email verification is unaffected.`, - link: "/profile", - }).catch(() => {}) - ); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] reject student college error"); - return res.status(500).json({ error: "Failed to reject college." }); - } -} - -// ── GET /api/admin/users ───────────────────────────────────────────────────── -// Searchable, paginated user list backing the "Login As" table. Admin -// accounts are excluded entirely — impersonating another admin isn't a -// supported flow (see the guard in startImpersonation too). -export async function listUsers(req, res) { - try { - const { role, search, college, page = 1, limit = 20 } = req.query; - - const andClauses = [{ role: { $ne: "admin" } }]; - if (role && ["student", "recruiter", "tpo"].includes(role)) { - andClauses.push({ role }); - } - if (search) { - andClauses.push({ - $or: [ - { displayName: { $regex: search, $options: "i" } }, - { email: { $regex: search, $options: "i" } }, - { username: { $regex: search, $options: "i" } }, - ], - }); - } - // Plan 005's "View students" deep-link: ?college=. Matches - // either linkage mechanism (see collegeController.js's getColleges for - // the same two-mechanism reasoning) — a student via education.collegeId, - // or a TPO via tpoProfile.collegeDomain against this college's domains. - // Recruiters/admins can never match either branch, so they're naturally - // excluded without a separate role check. - if (college) { - const collegeDoc = await College.findById(college).lean(); - if (!collegeDoc) { - return res.status(404).json({ error: "College not found." }); - } - andClauses.push({ - $or: [ - { "education.collegeId": collegeDoc._id }, - { "tpoProfile.collegeDomain": { $in: collegeDoc.domains } }, - ], - }); - } - - const filter = andClauses.length > 1 ? { $and: andClauses } : andClauses[0]; - - const pageNum = Math.max(1, parseInt(page)); - const limitNum = Math.min(50, parseInt(limit)); - - const [users, total] = await Promise.all([ - User.find( - filter, - "displayName email username role status recruiterProfile.companyName recruiterProfile.verified tpoProfile.collegeName tpoProfile.verified createdAt" - ) - .sort({ createdAt: -1 }) - .skip((pageNum - 1) * limitNum) - .limit(limitNum) - .lean(), - User.countDocuments(filter), - ]); - - return res.json({ - users: users.map((u) => ({ - id: u._id, - displayName: u.displayName, - email: u.email, - username: u.username, - role: u.role, - status: u.status || "active", - label: - u.role === "recruiter" - ? u.recruiterProfile?.companyName - : u.role === "tpo" - ? u.tpoProfile?.collegeName - : null, - verified: - u.role === "recruiter" - ? Boolean(u.recruiterProfile?.verified) - : u.role === "tpo" - ? Boolean(u.tpoProfile?.verified) - : true, - joinedAt: u.createdAt, - })), - total, - page: pageNum, - limit: limitNum, - }); - } catch (err) { - logger.error({ err }, "[Admin] users list error"); - return res.status(500).json({ error: "Failed to load users." }); - } -} - -// ── GET /api/admin/audit-logs ──────────────────────────────────────────────── -// Paginated, filterable read of the durable admin-action trail written by -// services/adminAuditLog.js's recordAdminAction(...). Append-only — there is -// no corresponding update/delete route. -export async function getAuditLogs(req, res) { - try { - const { action, adminId, startDate, endDate, page = 1, limit = 20 } = req.query; - - const filter = {}; - if (action) filter.action = action; - if (adminId) filter.adminId = adminId; - if (startDate || endDate) { - filter.createdAt = {}; - if (startDate) filter.createdAt.$gte = new Date(startDate); - if (endDate) filter.createdAt.$lte = new Date(endDate); - } - - const pageNum = Math.max(1, parseInt(page)); - const limitNum = Math.min(50, parseInt(limit)); - - const [logs, total] = await Promise.all([ - AdminAuditLog.find(filter) - .sort({ createdAt: -1 }) - .skip((pageNum - 1) * limitNum) - .limit(limitNum) - .lean(), - AdminAuditLog.countDocuments(filter), - ]); - - return res.json({ - logs, - total, - page: pageNum, - limit: limitNum, - }); - } catch (err) { - logger.error({ err }, "[Admin] audit logs error"); - return res.status(500).json({ error: "Failed to load audit logs." }); - } -} - -// ── GET /api/admin/dashboard-metrics ───────────────────────────────────────── -// Single response, all metrics — the frontend renders them together as a -// grid of stat cards (plan 004), no reason to round-trip once per card. -// Current-state snapshot only (no time-series/trends — that's plan 007). -// -// "Active" per role uses status: "active" (plan 003's User.status field, -// already landed by the time this was written — no fallback needed). -// -// Total Problems mirrors getProblems' (problemController.js) own catalog -// visibility filter exactly (`visibility: { $ne: "contest" }`) so this -// number always matches what the public problem list would show, not an -// internal total that includes contest-only problems. -// -// Submission counts / acceptance rate read the same Submission collection -// and "status === 'Accepted'" semantics as getAcceptanceRates -// (problemController.js) — same source of truth, collapsed to a single -// platform-wide number instead of per-problem. This is a plain -// Submission collection with a flat status field (not denormalized -// per-problem stats), so a platform-wide count is cheap and accurate — -// no escape-hatch situation here. -// -// Pending recruiter/TPO approvals mirror getPendingQueue's two query -// branches above, collapsed to countDocuments instead of find + full -// document fetch. -export async function getDashboardMetrics(req, res) { - try { - const startOfToday = new Date(); - startOfToday.setUTCHours(0, 0, 0, 0); - - const [ - totalStudents, - totalRecruiters, - totalTpos, - activeStudents, - activeRecruiters, - activeTpos, - newRegistrationsToday, - totalProblems, - totalSubmissions, - acceptedSubmissions, - pendingRecruiterApprovals, - pendingTpoApprovals, - ] = await Promise.all([ - User.countDocuments({ role: "student" }), - User.countDocuments({ role: "recruiter" }), - User.countDocuments({ role: "tpo" }), - User.countDocuments({ role: "student", status: "active" }), - User.countDocuments({ role: "recruiter", status: "active" }), - User.countDocuments({ role: "tpo", status: "active" }), - User.countDocuments({ createdAt: { $gte: startOfToday } }), - Problem.countDocuments({ visibility: { $ne: "contest" } }), - Submission.countDocuments({}), - Submission.countDocuments({ status: "Accepted" }), - User.countDocuments({ role: "recruiter", "recruiterProfile.verified": false }), - College.countDocuments({ status: "pending", submittedByRole: "tpo" }), - ]); - - const acceptanceRate = - totalSubmissions > 0 ? Math.round((acceptedSubmissions / totalSubmissions) * 100) : 0; - - return res.json({ - users: { - totalStudents, - totalRecruiters, - totalTpos, - activeStudents, - activeRecruiters, - activeTpos, - newRegistrationsToday, - }, - content: { - totalProblems, - totalSubmissions, - acceptanceRate, - }, - approvals: { - pendingRecruiterApprovals, - pendingTpoApprovals, - }, - }); - } catch (err) { - logger.error({ err }, "[Admin] dashboard metrics error"); - return res.status(500).json({ error: "Failed to load dashboard metrics." }); - } -} - -// ── User management actions (plan 003) ─────────────────────────────────────── -// Every action below: validates target exists and isn't an admin, performs -// its mutation, invalidates the auth cache so it takes effect immediately -// (not after the cache TTL), and audit-logs via recordAdminAction (plan 002). - -// The "progress" field list for resetUserProgress, enumerated from the full -// User schema (backend/models/User.js) per plan 003's instruction not to -// guess field names. Split into what's unambiguously progress (reset) vs. -// what's ambiguous enough that this plan deliberately leaves untouched -// rather than guess — see the comment above PROGRESS_RESET_FIELDS below. -const PROGRESS_RESET_FIELDS = { - currentStreak: 0, - longestStreak: 0, - lastActivityDate: null, - totalXP: 0, - solvedSlugs: [], - solvedDifficulty: { easy: 0, medium: 0, hard: 0 }, - topicStats: {}, - activityDates: [], - recentActivity: [], - achievements: [], - dailyChallengeHistory: [], -}; -// Deliberately NOT included above, flagged as ambiguous rather than guessed -// (escape hatch, plan 003): profileSignature (derived hash OF solvedCount — -// resetting solved data without it leaves a stale/inconsistent signature, -// but it's arguably a "profile" artifact, not progress itself); certificates -// (earned via completing tracks — progress-shaped, but the plan named only -// "achievements" explicitly, not this); pinnedProblems (user's curated -// showcase of solved problems — a curation choice, but references solved -// data); leetcodeStats (explicitly documented elsewhere in this file's model -// as NOT fed into totalXP/solvedSlugs, manually-entered supplementary -// content — leans profile); problemNotes (personal annotations on problems — -// could be either). None of these are touched by resetUserProgress below. -export async function suspendUser(req, res) { - try { - const target = await User.findById(req.params.id); - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Admins can't be suspended." }); - } - - target.status = "suspended"; - await target.save(); - invalidateCachedUserByFirebaseUid(target.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "user.suspend", - targetType: "User", - targetId: target._id, - }); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] suspendUser error"); - return res.status(500).json({ error: "Failed to suspend user." }); - } -} - -export async function activateUser(req, res) { - try { - const target = await User.findById(req.params.id); - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Admins don't have a status to activate." }); - } - - target.status = "active"; - await target.save(); - invalidateCachedUserByFirebaseUid(target.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "user.activate", - targetType: "User", - targetId: target._id, - }); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] activateUser error"); - return res.status(500).json({ error: "Failed to activate user." }); - } -} - -// Cascade decisions (grep -rn 'ref: "User"' backend/models/, done at -// planning time — see plan 003's maintenance note: revisit this if a new -// model starts referencing User): -// - Submission, Notification: cascade-deleted below. Both are meaningless -// without the owning user and, for Submission especially, leaving them -// orphaned would pollute leaderboards/analytics with phantom entries. -// - Everything else (Playlist, SkillsTest, RecruiterInterest, College, -// Contest, Reflection, Assignment, Ambassador, ImpersonationLog, -// AdminAuditLog, BattleRoom): left orphaned-but-harmless on purpose. -// Several of these are audit/historical records (ImpersonationLog, -// AdminAuditLog, SkillsTest) that should arguably survive their -// subject's deletion for accountability reasons, not be scrubbed by it. -// User.impersonating.targetUserId pointing at a deleted user is already -// self-healing — see middleware/auth.js's stale-pointer cleanup, which -// runs lazily on the admin's next request and needs no extra handling -// here. -// - College.primaryTpo (Phase 3, added after this cascade table was -// written): DOES need explicit handling, unlike the "orphaned but -// harmless" refs above — an ObjectId reference is technically still -// "harmless" sitting on a College doc, but every primary-only TPO -// team action (routes/tpo.js) resolves it via College.findOneAndUpdate -// CAS keyed on it, and there's no way to distinguish "primary account -// was deleted" from "primary account exists but the query missed it." -// Cleared here so the college falls back to "no primary yet" — the -// same safe, explicit state a college in "not claimed yet" already -// uses (tpoTeamService.js's claimPrimaryIfNone) — rather than an admin -// needing to notice and fix a silently-broken team page. No auto- -// promotion of a replacement primary happens here on purpose (item 6: -// don't silently invent ownership) — the next verified TPO to be -// approved (approveTpo above) or an existing verified secondary via -// the team UI can claim it. -export async function deleteUser(req, res) { - try { - const target = await User.findById(req.params.id); - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Admins can't be deleted." }); - } - - const { firebaseUid, _id } = target; - const tpoCollegeDomain = target.tpoProfile?.collegeDomain || null; - - await Promise.all([ - Submission.deleteMany({ userId: _id }), - Notification.deleteMany({ userId: _id }), - ]); - await User.deleteOne({ _id }); - - // TPO-1 hardening: reordered to run AFTER the account is actually - // gone, not before. This used to run first — if the deletion below - // then failed partway (a real possibility Promise.all/deleteOne can - // hit), a still-existing, still-valid primary TPO would have their - // primary status silently stripped for no reason, while continuing - // to exist and believing themselves still primary (every primary- - // only action they take would then wrongly 403). Clearing it after - // deletion instead means a failure in THIS step leaves a dangling - // primaryTpo reference — but that degrades into the same "no primary - // yet" state the rest of the system already handles safely (rule 4), - // recoverable via the team endpoints' admin override, rather than - // wrongly demoting someone who was never actually deleted. Best- - // effort and isolated in its own try/catch for the same reason: the - // account deletion itself already succeeded by this point and must - // be reported as success regardless of this cleanup step's outcome. - if (tpoCollegeDomain) { - try { - const college = await College.findByDomain(tpoCollegeDomain); - if (college) await clearPrimaryIfCurrent(college._id, _id); - } catch (err) { - logger.error( - { err, userId: _id.toString() }, - "[Admin] deleteUser: failed to clear dangling primaryTpo reference after successful deletion" - ); - } - } - - invalidateCachedUserByFirebaseUid(firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "user.delete", - targetType: "User", - targetId: _id, - }); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] deleteUser error"); - return res.status(500).json({ error: "Failed to delete user." }); - } -} - -export async function resetUserProgress(req, res) { - try { - const target = await User.findById(req.params.id); - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Admins don't have progress to reset." }); - } - - Object.assign(target, PROGRESS_RESET_FIELDS); - await target.save(); - invalidateCachedUserByFirebaseUid(target.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "user.reset_progress", - targetType: "User", - targetId: target._id, - }); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] resetUserProgress error"); - return res.status(500).json({ error: "Failed to reset user progress." }); - } -} - -const CHANGEABLE_ROLES = ["student", "recruiter", "tpo"]; - -export async function changeUserRole(req, res) { - try { - const { role: newRole } = req.body || {}; - - if (!CHANGEABLE_ROLES.includes(newRole)) { - return res.status(400).json({ - error: `Role must be one of: ${CHANGEABLE_ROLES.join(", ")}.`, - }); - } - - const target = await User.findById(req.params.id); - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Admins' roles can't be changed here." }); - } - - const previousRole = target.role; - target.role = newRole; - await target.save(); - invalidateCachedUserByFirebaseUid(target.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "user.change_role", - targetType: "User", - targetId: target._id, - details: { previousRole, newRole }, - }); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] changeUserRole error"); - return res.status(500).json({ error: "Failed to change user role." }); - } -} - -// ── POST /api/admin/impersonate/:userId ───────────────────────────────────── -export async function startImpersonation(req, res) { - try { - // req.actingAdminDoc is only set while already impersonating (switching - // targets directly); otherwise req.userDoc IS the real admin. - const adminDoc = req.actingAdminDoc || req.userDoc; - const target = await User.findById(req.params.userId); - - if (!target) { - return res.status(404).json({ error: "User not found." }); - } - if (target.role === "admin") { - return res.status(400).json({ error: "Impersonating another admin isn't supported." }); - } - if (String(target._id) === String(adminDoc._id)) { - return res.status(400).json({ error: "You can't impersonate yourself." }); - } - - // Switching targets mid-impersonation — close out the previous log entry. - if (adminDoc.impersonating?.targetUserId) { - await ImpersonationLog.updateOne( - { - adminId: adminDoc._id, - targetUserId: adminDoc.impersonating.targetUserId, - endedAt: null, - }, - { $set: { endedAt: new Date() } } - ); - } - - const now = new Date(); - adminDoc.impersonating = { targetUserId: target._id, startedAt: now }; - await adminDoc.save(); - - await ImpersonationLog.create({ - adminId: adminDoc._id, - adminEmail: adminDoc.email, - targetUserId: target._id, - targetEmail: target.email, - targetRole: target.role, - startedAt: now, - }); - - return res.json({ - success: true, - impersonating: { - id: target._id, - email: target.email, - displayName: target.displayName, - role: target.role, - }, - }); - } catch (err) { - logger.error({ err }, "[Admin] impersonate start error"); - return res.status(500).json({ error: "Failed to start impersonation." }); - } -} - -// ── POST /api/admin/impersonate/stop ──────────────────────────────────────── -export async function stopImpersonation(req, res) { - try { - const adminDoc = req.actingAdminDoc || req.userDoc; - - if (adminDoc.impersonating?.targetUserId) { - await ImpersonationLog.updateOne( - { - adminId: adminDoc._id, - targetUserId: adminDoc.impersonating.targetUserId, - endedAt: null, - }, - { $set: { endedAt: new Date() } } - ); - } - - adminDoc.impersonating = { targetUserId: null, startedAt: null }; - await adminDoc.save(); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] impersonate stop error"); - return res.status(500).json({ error: "Failed to stop impersonation." }); - } -} - -async function resolvePendingTpoCollege(user) { - const domain = user?.tpoProfile?.collegeDomain; - if (!domain) return null; - return College.findByDomain(domain); -} - -export async function approveTpoUser(req, res) { - try { - const user = await User.findById(req.params.userId); - if (!user || user.role !== "tpo") { - return res.status(404).json({ error: "TPO verification request not found." }); - } - if (user.tpoProfile?.verified) { - return res.json({ success: true, alreadyVerified: true }); - } - - const college = await resolvePendingTpoCollege(user); - if (!college || college.status !== "verified") { - return res.status(409).json({ - error: "The TPO's college must be verified before individual TPO access can be approved.", - }); - } - - const now = new Date(); - user.tpoProfile.verified = true; - user.tpoProfile.verifiedAt = now; - user.tpoVerification = { - ...(user.tpoVerification || {}), - status: "approved", - }; - await user.save(); - invalidateCachedUserByFirebaseUid(user.firebaseUid); - - try { - await TpoVerificationReview.create({ - userId: user._id, - collegeId: college._id, - requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", - emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", - evidence: user.tpoVerification?.evidence || [], - decision: "approved", - decisionReason: "Approved through individual TPO verification.", - reviewedBy: req.actingAdminDoc?._id || req.userDoc?._id, - reviewedAt: now, - }); - } catch (err) { - logger.error({ err, userId: user._id }, "[Admin] approveTpoUser: audit write failed"); - } - - try { - await claimPrimaryIfNone(college._id, user._id); - } catch (err) { - logger.error({ err, collegeId: college._id, userId: user._id }, "[Admin] approveTpoUser primary claim failed"); - } - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.user.approve", - targetType: "User", - targetId: user._id, - details: { collegeId: college._id }, - }); - - createNotification({ - userId: user._id, - type: "tpo_verified", - title: "TPO access approved", - message: college.name + " TPO access is now verified.", - link: "/tpo/dashboard", - }).catch(() => {}); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] approve TPO user error"); - return res.status(500).json({ error: "Failed to approve TPO verification." }); - } -} - -export async function rejectTpoUser(req, res) { - try { - const user = await User.findById(req.params.userId); - if (!user || user.role !== "tpo") { - return res.status(404).json({ error: "TPO verification request not found." }); - } - - const college = await resolvePendingTpoCollege(user); - const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; - const now = new Date(); - - if (reviewerId && college) { - try { - await TpoVerificationReview.create({ - userId: user._id, - collegeId: college._id, - requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", - emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", - evidence: user.tpoVerification?.evidence || [], - decision: "rejected", - decisionReason: "Individual TPO verification request rejected.", - reviewedBy: reviewerId, - reviewedAt: now, - }); - } catch (err) { - logger.error({ err, userId: user._id }, "[Admin] rejectTpoUser: audit write failed"); - } - } - - user.revokeRole("tpo"); - user.role = "student"; - user.tpoProfile = { - collegeDomain: null, - collegeName: null, - verified: false, - requestedAt: null, - verifiedAt: null, - }; - user.tpoVerification = { - ...(user.tpoVerification || {}), - status: "rejected", - }; - await user.save(); - invalidateCachedUserByFirebaseUid(user.firebaseUid); - - recordAdminAction({ - adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.user.reject", - targetType: "User", - targetId: user._id, - details: { collegeId: college?._id || null }, - }); - - createNotification({ - userId: user._id, - type: "tpo_rejected", - title: "TPO access request declined", - message: "We couldn't verify your TPO access request. Reach out if this was a mistake.", - link: "/tpo/signup", - }).catch(() => {}); - - return res.json({ success: true }); - } catch (err) { - logger.error({ err }, "[Admin] reject TPO user error"); - return res.status(500).json({ error: "Failed to reject TPO verification." }); - } -} + try { \ No newline at end of file From ece5c93c2a839bc249aa080c5593b4fe16dad961 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:51 +0530 Subject: [PATCH 12/36] fix(tpo): preserve admin controller and add individual verification queue --- backend/controllers/adminController.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 09c3c836..be3b9a96 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -67,6 +67,16 @@ export async function getPendingQueue(req, res) { ) .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) .lean(), + User.find( + { + role: "tpo", + "tpoProfile.verified": false, + "tpoVerification.status": "pending", + }, + "email displayName tpoProfile tpoVerification createdAt" + ) + .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) + .lean(), ]); const tpoColleges = pendingColleges.filter((c) => c.submittedByRole === "tpo"); @@ -83,6 +93,13 @@ export async function getPendingQueue(req, res) { (u) => !pendingCollegeRequesterIds.has(u._id.toString()) ); + const pendingCollegeRequesterIds = new Set( + tpoColleges.map((c) => c.submittedBy?._id?.toString()).filter(Boolean) + ); + const individualTpoRequests = pendingTpoUsers.filter( + (u) => !pendingCollegeRequesterIds.has(u._id.toString()) + ); + const studentColleges = pendingColleges.filter( (c) => c.submittedByRole === "student" || c.submittedByRole === "auto" ); From c2fa29dc5e8aa7a6d120bb559db898cf799a26b5 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:46:59 +0530 Subject: [PATCH 13/36] fix(tpo): remove duplicate pending verification query block --- backend/controllers/adminController.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index be3b9a96..cef0e232 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -93,13 +93,6 @@ export async function getPendingQueue(req, res) { (u) => !pendingCollegeRequesterIds.has(u._id.toString()) ); - const pendingCollegeRequesterIds = new Set( - tpoColleges.map((c) => c.submittedBy?._id?.toString()).filter(Boolean) - ); - const individualTpoRequests = pendingTpoUsers.filter( - (u) => !pendingCollegeRequesterIds.has(u._id.toString()) - ); - const studentColleges = pendingColleges.filter( (c) => c.submittedByRole === "student" || c.submittedByRole === "auto" ); From b2118d2147239a58fb8885f90dc24c02184bc726 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:18 +0530 Subject: [PATCH 14/36] fix(tpo): remove duplicate pending TPO query --- backend/controllers/adminController.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index cef0e232..09c3c836 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -67,16 +67,6 @@ export async function getPendingQueue(req, res) { ) .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) .lean(), - User.find( - { - role: "tpo", - "tpoProfile.verified": false, - "tpoVerification.status": "pending", - }, - "email displayName tpoProfile tpoVerification createdAt" - ) - .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) - .lean(), ]); const tpoColleges = pendingColleges.filter((c) => c.submittedByRole === "tpo"); From d1f736389324b5c0f1aa26b4f7c3e0c2fbe31c0d Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:26 +0530 Subject: [PATCH 15/36] feat(tpo): support individual TPO verification actions in admin queue --- src/hooks/useAdminVerificationQueue.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAdminVerificationQueue.js b/src/hooks/useAdminVerificationQueue.js index 37c9dac4..5ec0f633 100644 --- a/src/hooks/useAdminVerificationQueue.js +++ b/src/hooks/useAdminVerificationQueue.js @@ -66,18 +66,25 @@ export function useAdminVerificationQueue() { } } - async function actOnTpo(collegeId, action) { - setBusyIds((b) => ({ ...b, [collegeId]: action })); + async function actOnTpo(itemOrId, action) { + const item = typeof itemOrId === "object" ? itemOrId : { collegeId: itemOrId }; + const key = item.userId || item.collegeId; + setBusyIds((b) => ({ ...b, [key]: action })); try { - await apiFetch(`/api/admin/tpo/${collegeId}/${action}`, { method: "POST" }); - setTpos((list) => list.filter((t) => t.collegeId !== collegeId)); - toast.success(action === "approve" ? "College verified." : "TPO request rejected."); + if (item.reviewTarget === "user") { + await apiFetch(`/api/admin/tpo-verification/${item.userId}/${action}`, { method: "POST" }); + setTpos((list) => list.filter((t) => t.userId !== item.userId)); + } else { + await apiFetch(`/api/admin/tpo/${item.collegeId}/${action}`, { method: "POST" }); + setTpos((list) => list.filter((t) => t.collegeId !== item.collegeId)); + } + toast.success(action === "approve" ? "TPO verification approved." : "TPO request rejected."); } catch (err) { toast.error(err.message || `Failed to ${action} TPO request.`); } finally { setBusyIds((b) => { const next = { ...b }; - delete next[collegeId]; + delete next[key]; return next; }); } From d7f6af309d1cc7630aceebc3b4e18cbed782ba2c Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:32 +0530 Subject: [PATCH 16/36] chore(tpo): keep TPO queue action wiring compatible From 5edac136b662d0f335a6d0c030d5c933e125388f Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:40 +0530 Subject: [PATCH 17/36] feat(tpo): allow queue rows to carry individual review targets --- src/components/admin/VerificationQueueSection.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/admin/VerificationQueueSection.jsx b/src/components/admin/VerificationQueueSection.jsx index 21a3424b..3ca36fc5 100644 --- a/src/components/admin/VerificationQueueSection.jsx +++ b/src/components/admin/VerificationQueueSection.jsx @@ -95,8 +95,8 @@ function VerificationQueueSection({ heading, loading, emptyLabel, items, busyIds evidenceHint={row.evidenceHint} evidence={row.evidence} busy={busyIds[row.id]} - onApprove={() => onApprove(row.id)} - onReject={() => onReject(row.id)} + onApprove={() => onApprove(row.actionTarget ?? row.id)} + onReject={() => onReject(row.actionTarget ?? row.id)} /> ); })} From ff1122a361d53b587dd19142ca3b112321c01f64 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:45 +0530 Subject: [PATCH 18/36] feat(tpo): route individual TPO queue rows to user review --- src/pages/admin/AdminOverviewPage.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/admin/AdminOverviewPage.jsx b/src/pages/admin/AdminOverviewPage.jsx index 566bca71..cfebf7c0 100644 --- a/src/pages/admin/AdminOverviewPage.jsx +++ b/src/pages/admin/AdminOverviewPage.jsx @@ -84,7 +84,8 @@ export default function AdminOverviewPage() { items={tpos} busyIds={busyIds} getRow={(t) => ({ - id: t.collegeId, + id: t.userId || t.collegeId, + actionTarget: t.reviewTarget === "user" ? t : undefined, title: t.collegeName, subtitle: t.requestedBy?.displayName || t.requestedBy?.email || "Unknown requester", meta: `${(t.domains || []).join(", ")} · requested ${formatDate(t.requestedAt)}`, From c683a0178f42f95db505252ecd2da1134dd6b3ee Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:47:58 +0530 Subject: [PATCH 19/36] test(tpo): cover individual TPO verification decisions --- backend/controllers/adminController.test.js | 68 ++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/backend/controllers/adminController.test.js b/backend/controllers/adminController.test.js index ec5a3b85..300639b9 100644 --- a/backend/controllers/adminController.test.js +++ b/backend/controllers/adminController.test.js @@ -71,6 +71,8 @@ import { rejectRecruiter, approveTpo, rejectTpo, + approveTpoUser, + rejectTpoUser, approveStudentCollege, rejectStudentCollege, listUsers, @@ -1199,4 +1201,68 @@ describe("adminController", () => { expect(res.status).toHaveBeenCalledWith(500); }); }); -}); \ No newline at end of file +}); + +describe("individual TPO verification", () => { + it("approves a pending TPO only when its college is already verified", async () => { + const user = makeUser({ + _id: "t1", + role: "tpo", + roles: ["student", "tpo"], + email: "prof@staff.mit.edu", + firebaseUid: "fb-t1", + tpoProfile: { + collegeDomain: "staff.mit.edu", + collegeName: "MIT", + verified: false, + requestedAt: new Date(), + verifiedAt: null, + }, + tpoVerification: { + status: "pending", + emailRoleSignal: "staff_candidate", + submittedEmail: "prof@staff.mit.edu", + evidence: [], + }, + }); + const college = { _id: "c1", name: "MIT", status: "verified", domains: ["staff.mit.edu"], }; + User.findById.mockResolvedValueOnce(user); + College.findByDomain.mockResolvedValueOnce(college); + claimPrimaryIfNone.mockResolvedValueOnce(true); + + await approveTpoUser({ params: { userId: "t1" }, userDoc: makeAdmin() }, res); + + expect(user.tpoProfile.verified).toBe(true); + expect(user.tpoVerification.status).toBe("approved"); + expect(user.save).toHaveBeenCalled(); + expect(TpoVerificationReview.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: "t1", collegeId: "c1", decision: "approved" }) + ); + }); + + it("rejects an individual pending TPO without deleting the college", async () => { + const user = makeUser({ + _id: "t2", + role: "tpo", + roles: ["student", "tpo"], + tpoProfile: { + collegeDomain: "mit.edu", + collegeName: "MIT", + verified: false, + requestedAt: new Date(), + verifiedAt: null, + }, + tpoVerification: { status: "pending", emailRoleSignal: "student_candidate", evidence: [] }, + }); + User.findById.mockResolvedValueOnce(user); + College.findByDomain.mockResolvedValueOnce({ _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] }); + + await rejectTpoUser({ params: { userId: "t2" }, userDoc: makeAdmin() }, res); + + expect(user.roles).toEqual(["student"]); + expect(user.role).toBe("student"); + expect(user.tpoVerification.status).toBe("rejected"); + expect(College.deleteOne).not.toHaveBeenCalled(); + expect(user.save).toHaveBeenCalled(); + }); +}); From fca9e1bf49fb47c776e73c4bd7b03b890f244dc2 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:48:06 +0530 Subject: [PATCH 20/36] test(tpo): mock individual pending TPO queue in admin tests --- backend/controllers/adminController.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/controllers/adminController.test.js b/backend/controllers/adminController.test.js index 300639b9..c32cd30d 100644 --- a/backend/controllers/adminController.test.js +++ b/backend/controllers/adminController.test.js @@ -169,6 +169,7 @@ describe("adminController", () => { }, ]) ); + User.find.mockReturnValueOnce(chainableQuery([])); const req = {}; await getPendingQueue(req, res); From 39df6622acfc8bd447b63901919cd49effd7e8cf Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:48:12 +0530 Subject: [PATCH 21/36] docs(tpo): document institutional email-role verification flow --- docs/api-contracts.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 5b37bff1..218728cc 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -167,3 +167,16 @@ Institution subscription enforcement is controlled by `B2B_BILLING_ENABLED`, ind | POST | `/api/tpo/billing/verify` | Verifies Razorpay HMAC, order metadata, amount/currency, and payment identity before activating the college plan. | Current launch catalog is maintained in `config/featureFlags.js` as `B2B_PRICING`. Institution checkout is backed by the shared Razorpay webhook endpoint (`/api/billing/webhook`) using `billingType: "institution"` and a separate `RAZORPAY_B2B_WEBHOOK_SECRET` when configured. Webhook delivery is idempotent via Razorpay's `x-razorpay-event-id`; failed processing is left retryable. + + +### TPO email-role verification + +College email-role patterns are institution-specific advisory evidence. They may classify an authenticated institutional email as `staff_candidate`, `student_candidate`, `ambiguous`, or `unknown`; they never grant TPO authorization. + +| Method | Path | Purpose | +|---|---|---| +| PATCH | `/api/admin/colleges/:collegeId/email-role-patterns` | Admin-only configuration of staff/student email patterns for a college. | +| POST | `/api/admin/tpo-verification/:userId/approve` | Admin-only approval of an individual pending TPO request after the college is verified. | +| POST | `/api/admin/tpo-verification/:userId/reject` | Admin-only rejection of an individual pending TPO request. | + +TPO registration keeps the requester pending even when the institution itself is already recognized. This separates **institution trust** from **individual TPO authorization**. Student/staff email patterns are evidence shown to the reviewer, not an authorization shortcut. From d331bd0a4ad8c0da1139209e2c4acd66e9713742 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:48:33 +0530 Subject: [PATCH 22/36] fix(tpo): restore admin controller and apply individual verification cleanly --- backend/controllers/adminController.js | 1064 +++++++++++++++++++++++- 1 file changed, 1063 insertions(+), 1 deletion(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 09c3c836..60e87de4 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -158,4 +158,1066 @@ export async function getPendingQueue(req, res) { // ── POST /api/admin/recruiters/:id/approve ────────────────────────────────── export async function approveRecruiter(req, res) { - try { \ No newline at end of file + try { + const user = await User.findById(req.params.id); + + if (!user || user.role !== "recruiter") { + return res.status(404).json({ error: "Recruiter not found." }); + } + + user.recruiterProfile.verified = true; + user.recruiterProfile.verifiedAt = new Date(); + await user.save(); + // This user's own requireAuth cache entry (on whichever instance they + // next hit) would otherwise still show `verified: false` for up to + // AUTH_USER_CACHE_TTL_MS — drop it on this instance now so a request + // that happens to land here sees the fresh doc immediately. + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "recruiter.approve", + targetType: "User", + targetId: user._id, + }); + + createNotification({ + userId: user._id, + type: "recruiter_verified", + title: "Recruiter access approved", + message: `You're verified for ${user.recruiterProfile.companyName}. Your dashboard is ready.`, + link: "/recruiter/dashboard", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] approve recruiter error"); + return res.status(500).json({ error: "Failed to approve recruiter." }); + } +} + +// ── POST /api/admin/recruiters/:id/reject ─────────────────────────────────── +export async function rejectRecruiter(req, res) { + try { + const user = await User.findById(req.params.id); + + if (!user || user.role !== "recruiter") { + return res.status(404).json({ error: "Recruiter not found." }); + } + + const companyName = user.recruiterProfile?.companyName; + + // Revoke the "recruiter" authorization (not just the active role) so + // this account can no longer switch back into a recruiter session via + // POST /me/switch-role — matches the additive grantRole() at + // registration. Falls active role back to "student", which every + // account is authorized for by default. + user.revokeRole("recruiter"); + user.role = "student"; + user.recruiterProfile = { + companyName: null, + designation: null, + companyDomain: null, + verified: false, + verifiedAt: null, + }; + await user.save(); + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "recruiter.reject", + targetType: "User", + targetId: user._id, + }); + + createNotification({ + userId: user._id, + type: "recruiter_rejected", + title: "Recruiter access request declined", + message: companyName + ? `We couldn't verify your request for ${companyName}. Reach out if this was a mistake.` + : "We couldn't verify your recruiter access request.", + link: "/recruiter/signup", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] reject recruiter error"); + return res.status(500).json({ error: "Failed to reject recruiter." }); + } +} + +// Shared by approveTpo/rejectTpo and approveStudentCollege/rejectStudentCollege +// — flips the institution's own trust state. Callers are responsible for +// whatever role-specific follow-up (tpoProfile sync, education.collegeStatus +// sync) their submitter type needs. +async function setCollegeStatus(collegeId, status) { + const college = await College.findById(collegeId); + if (!college) return null; + college.status = status; + college.verifiedAt = status === "verified" ? new Date() : null; + await college.save(); + return college; +} + +// ── POST /api/admin/tpo/:collegeId/approve ────────────────────────────────── +export async function approveTpo(req, res) { + try { + const college = await setCollegeStatus(req.params.collegeId, "verified"); + if (!college) return res.status(404).json({ error: "College request not found." }); + + const pendingCandidates = await User.find({ + role: "tpo", + "tpoProfile.collegeDomain": { $in: college.domains }, + "tpoProfile.verified": false, + }) + .sort({ "tpoProfile.requestedAt": 1, _id: 1 }) + .select("_id firebaseUid email tpoVerification") + .lean(); + + await User.updateMany( + { role: "tpo", "tpoProfile.collegeDomain": { $in: college.domains }, "tpoProfile.verified": false }, + { + $set: { + "tpoProfile.verified": true, + "tpoProfile.verifiedAt": college.verifiedAt, + "tpoVerification.status": "approved", + }, + } + ); + + pendingCandidates.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); + + if (pendingCandidates.length > 0) { + try { + await claimPrimaryIfNone(college._id, pendingCandidates[0]._id); + } catch (err) { + logger.error({ err, collegeId: college._id }, "[Admin] approveTpo primary claim failed"); + } + } + + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + if (reviewerId) { + try { + await Promise.all( + pendingCandidates.map((u) => + TpoVerificationReview.create({ + userId: u._id, + collegeId: college._id, + requestedEmail: u.tpoVerification?.submittedEmail || u.email || "", + emailRoleSignal: u.tpoVerification?.emailRoleSignal || "unknown", + evidence: u.tpoVerification?.evidence || [], + decision: "approved", + decisionReason: "Approved through the administrative TPO verification queue.", + reviewedBy: reviewerId, + reviewedAt: college.verifiedAt || new Date(), + }) + ) + ); + } catch (err) { + logger.error( + { err, collegeId: college._id }, + "[Admin] approveTpo: failed to persist verification review audit" + ); + } + } + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.approve", + targetType: "College", + targetId: college._id, + }); + + if (college.submittedBy) { + createNotification({ + userId: college.submittedBy, + type: "tpo_verified", + title: "TPO access approved", + message: college.name + " is verified. Your placement dashboard is ready.", + link: "/tpo/dashboard", + }).catch(() => {}); + } + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] approve TPO error"); + return res.status(500).json({ error: "Failed to approve TPO." }); + } +} + + +// ── POST /api/admin/tpo/:collegeId/reject ─────────────────────────────────── +export async function rejectTpo(req, res) { + try { + const college = await College.findById(req.params.collegeId); + if (!college) return res.status(404).json({ error: "College request not found." }); + + const requesterId = college.submittedBy; + const collegeName = college.name; + const requester = requesterId + ? await User.findById(requesterId) + : null; + + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + if (reviewerId && requester) { + try { + await TpoVerificationReview.create({ + userId: requester._id, + collegeId: college._id, + requestedEmail: requester.tpoVerification?.submittedEmail || requester.email || "", + emailRoleSignal: requester.tpoVerification?.emailRoleSignal || "unknown", + evidence: requester.tpoVerification?.evidence || [], + decision: "rejected", + decisionReason: "TPO request rejected through the administrative verification queue.", + reviewedBy: reviewerId, + reviewedAt: new Date(), + }); + } catch (err) { + logger.error( + { err, collegeId: college._id }, + "[Admin] rejectTpo: failed to persist verification review audit" + ); + } + } + + await College.deleteOne({ _id: college._id }); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.reject", + targetType: "College", + targetId: college._id, + }); + + if (requester && requester.role === "tpo") { + requester.revokeRole("tpo"); + requester.role = "student"; + requester.tpoProfile = { + collegeDomain: null, + collegeName: null, + verified: false, + requestedAt: null, + verifiedAt: null, + }; + requester.tpoVerification = { + ...(requester.tpoVerification || {}), + status: "rejected", + emailRoleSignal: requester.tpoVerification?.emailRoleSignal || "unknown", + submittedEmail: requester.tpoVerification?.submittedEmail || requester.email || null, + submittedAt: requester.tpoVerification?.submittedAt || null, + evidence: requester.tpoVerification?.evidence || [], + }; + await requester.save(); + invalidateCachedUserByFirebaseUid(requester.firebaseUid); + + createNotification({ + userId: requesterId, + type: "tpo_rejected", + title: "TPO access request declined", + message: "We couldn't verify your request for " + collegeName + ". Reach out if this was a mistake.", + link: "/tpo/signup", + }).catch(() => {}); + } + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] reject TPO error"); + return res.status(500).json({ error: "Failed to reject TPO request." }); + } +} + + +// ── POST /api/admin/student-colleges/:collegeId/approve ──────────────────── +// Approves a college that was requested via a student's college-email +// verification (backend/routes/collegeVerification.js), as opposed to a TPO +// registration. Pushes the new status to every user whose education is +// linked to this college and has already verified their email — mirrors the +// tpoProfile-sync pattern in approveTpo above, applied to `education`. +export async function approveStudentCollege(req, res) { + try { + const college = await setCollegeStatus(req.params.collegeId, "verified"); + if (!college) { + return res.status(404).json({ error: "College request not found." }); + } + + const affected = await User.find({ + "education.collegeId": college._id, + "education.emailVerified": true, + }); + + await Promise.all( + affected.map((u) => { + u.education.collegeStatus = "verified"; + return u.save(); + }) + ); + + affected.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "studentCollege.approve", + targetType: "College", + targetId: college._id, + }); + + affected.forEach((u) => + createNotification({ + userId: u._id, + type: "college_verified", + title: "Your college is now verified", + message: `${college.name} has been added to Code Club's verified colleges. Your College Leaderboard is unlocked.`, + link: "/club/leaderboard", + }).catch(() => {}) + ); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] approve student college error"); + return res.status(500).json({ error: "Failed to approve college." }); + } +} + +// ── POST /api/admin/student-colleges/:collegeId/reject ────────────────────── +// Unlike rejectTpo, this does NOT delete the College doc — the record is +// kept with status:"rejected" so a resubmission for the same domain is +// recognized as "already reviewed" (see the 409 check in +// collegeVerification.js's findOrCreatePendingCollege) rather than silently +// re-queuing a previously-rejected institution. +export async function rejectStudentCollege(req, res) { + try { + const college = await setCollegeStatus(req.params.collegeId, "rejected"); + if (!college) { + return res.status(404).json({ error: "College request not found." }); + } + + const affected = await User.find({ + "education.collegeId": college._id, + "education.emailVerified": true, + }); + + await Promise.all( + affected.map((u) => { + u.education.collegeStatus = "rejected"; + return u.save(); + }) + ); + + affected.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "studentCollege.reject", + targetType: "College", + targetId: college._id, + }); + + affected.forEach((u) => + createNotification({ + userId: u._id, + type: "college_rejected", + title: "College verification update", + message: `We weren't able to verify ${college.name} for official College Leaderboard status. Your email verification is unaffected.`, + link: "/profile", + }).catch(() => {}) + ); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] reject student college error"); + return res.status(500).json({ error: "Failed to reject college." }); + } +} + +// ── GET /api/admin/users ───────────────────────────────────────────────────── +// Searchable, paginated user list backing the "Login As" table. Admin +// accounts are excluded entirely — impersonating another admin isn't a +// supported flow (see the guard in startImpersonation too). +export async function listUsers(req, res) { + try { + const { role, search, college, page = 1, limit = 20 } = req.query; + + const andClauses = [{ role: { $ne: "admin" } }]; + if (role && ["student", "recruiter", "tpo"].includes(role)) { + andClauses.push({ role }); + } + if (search) { + andClauses.push({ + $or: [ + { displayName: { $regex: search, $options: "i" } }, + { email: { $regex: search, $options: "i" } }, + { username: { $regex: search, $options: "i" } }, + ], + }); + } + // Plan 005's "View students" deep-link: ?college=. Matches + // either linkage mechanism (see collegeController.js's getColleges for + // the same two-mechanism reasoning) — a student via education.collegeId, + // or a TPO via tpoProfile.collegeDomain against this college's domains. + // Recruiters/admins can never match either branch, so they're naturally + // excluded without a separate role check. + if (college) { + const collegeDoc = await College.findById(college).lean(); + if (!collegeDoc) { + return res.status(404).json({ error: "College not found." }); + } + andClauses.push({ + $or: [ + { "education.collegeId": collegeDoc._id }, + { "tpoProfile.collegeDomain": { $in: collegeDoc.domains } }, + ], + }); + } + + const filter = andClauses.length > 1 ? { $and: andClauses } : andClauses[0]; + + const pageNum = Math.max(1, parseInt(page)); + const limitNum = Math.min(50, parseInt(limit)); + + const [users, total] = await Promise.all([ + User.find( + filter, + "displayName email username role status recruiterProfile.companyName recruiterProfile.verified tpoProfile.collegeName tpoProfile.verified createdAt" + ) + .sort({ createdAt: -1 }) + .skip((pageNum - 1) * limitNum) + .limit(limitNum) + .lean(), + User.countDocuments(filter), + ]); + + return res.json({ + users: users.map((u) => ({ + id: u._id, + displayName: u.displayName, + email: u.email, + username: u.username, + role: u.role, + status: u.status || "active", + label: + u.role === "recruiter" + ? u.recruiterProfile?.companyName + : u.role === "tpo" + ? u.tpoProfile?.collegeName + : null, + verified: + u.role === "recruiter" + ? Boolean(u.recruiterProfile?.verified) + : u.role === "tpo" + ? Boolean(u.tpoProfile?.verified) + : true, + joinedAt: u.createdAt, + })), + total, + page: pageNum, + limit: limitNum, + }); + } catch (err) { + logger.error({ err }, "[Admin] users list error"); + return res.status(500).json({ error: "Failed to load users." }); + } +} + +// ── GET /api/admin/audit-logs ──────────────────────────────────────────────── +// Paginated, filterable read of the durable admin-action trail written by +// services/adminAuditLog.js's recordAdminAction(...). Append-only — there is +// no corresponding update/delete route. +export async function getAuditLogs(req, res) { + try { + const { action, adminId, startDate, endDate, page = 1, limit = 20 } = req.query; + + const filter = {}; + if (action) filter.action = action; + if (adminId) filter.adminId = adminId; + if (startDate || endDate) { + filter.createdAt = {}; + if (startDate) filter.createdAt.$gte = new Date(startDate); + if (endDate) filter.createdAt.$lte = new Date(endDate); + } + + const pageNum = Math.max(1, parseInt(page)); + const limitNum = Math.min(50, parseInt(limit)); + + const [logs, total] = await Promise.all([ + AdminAuditLog.find(filter) + .sort({ createdAt: -1 }) + .skip((pageNum - 1) * limitNum) + .limit(limitNum) + .lean(), + AdminAuditLog.countDocuments(filter), + ]); + + return res.json({ + logs, + total, + page: pageNum, + limit: limitNum, + }); + } catch (err) { + logger.error({ err }, "[Admin] audit logs error"); + return res.status(500).json({ error: "Failed to load audit logs." }); + } +} + +// ── GET /api/admin/dashboard-metrics ───────────────────────────────────────── +// Single response, all metrics — the frontend renders them together as a +// grid of stat cards (plan 004), no reason to round-trip once per card. +// Current-state snapshot only (no time-series/trends — that's plan 007). +// +// "Active" per role uses status: "active" (plan 003's User.status field, +// already landed by the time this was written — no fallback needed). +// +// Total Problems mirrors getProblems' (problemController.js) own catalog +// visibility filter exactly (`visibility: { $ne: "contest" }`) so this +// number always matches what the public problem list would show, not an +// internal total that includes contest-only problems. +// +// Submission counts / acceptance rate read the same Submission collection +// and "status === 'Accepted'" semantics as getAcceptanceRates +// (problemController.js) — same source of truth, collapsed to a single +// platform-wide number instead of per-problem. This is a plain +// Submission collection with a flat status field (not denormalized +// per-problem stats), so a platform-wide count is cheap and accurate — +// no escape-hatch situation here. +// +// Pending recruiter/TPO approvals mirror getPendingQueue's two query +// branches above, collapsed to countDocuments instead of find + full +// document fetch. +export async function getDashboardMetrics(req, res) { + try { + const startOfToday = new Date(); + startOfToday.setUTCHours(0, 0, 0, 0); + + const [ + totalStudents, + totalRecruiters, + totalTpos, + activeStudents, + activeRecruiters, + activeTpos, + newRegistrationsToday, + totalProblems, + totalSubmissions, + acceptedSubmissions, + pendingRecruiterApprovals, + pendingTpoApprovals, + ] = await Promise.all([ + User.countDocuments({ role: "student" }), + User.countDocuments({ role: "recruiter" }), + User.countDocuments({ role: "tpo" }), + User.countDocuments({ role: "student", status: "active" }), + User.countDocuments({ role: "recruiter", status: "active" }), + User.countDocuments({ role: "tpo", status: "active" }), + User.countDocuments({ createdAt: { $gte: startOfToday } }), + Problem.countDocuments({ visibility: { $ne: "contest" } }), + Submission.countDocuments({}), + Submission.countDocuments({ status: "Accepted" }), + User.countDocuments({ role: "recruiter", "recruiterProfile.verified": false }), + College.countDocuments({ status: "pending", submittedByRole: "tpo" }), + ]); + + const acceptanceRate = + totalSubmissions > 0 ? Math.round((acceptedSubmissions / totalSubmissions) * 100) : 0; + + return res.json({ + users: { + totalStudents, + totalRecruiters, + totalTpos, + activeStudents, + activeRecruiters, + activeTpos, + newRegistrationsToday, + }, + content: { + totalProblems, + totalSubmissions, + acceptanceRate, + }, + approvals: { + pendingRecruiterApprovals, + pendingTpoApprovals, + }, + }); + } catch (err) { + logger.error({ err }, "[Admin] dashboard metrics error"); + return res.status(500).json({ error: "Failed to load dashboard metrics." }); + } +} + +// ── User management actions (plan 003) ─────────────────────────────────────── +// Every action below: validates target exists and isn't an admin, performs +// its mutation, invalidates the auth cache so it takes effect immediately +// (not after the cache TTL), and audit-logs via recordAdminAction (plan 002). + +// The "progress" field list for resetUserProgress, enumerated from the full +// User schema (backend/models/User.js) per plan 003's instruction not to +// guess field names. Split into what's unambiguously progress (reset) vs. +// what's ambiguous enough that this plan deliberately leaves untouched +// rather than guess — see the comment above PROGRESS_RESET_FIELDS below. +const PROGRESS_RESET_FIELDS = { + currentStreak: 0, + longestStreak: 0, + lastActivityDate: null, + totalXP: 0, + solvedSlugs: [], + solvedDifficulty: { easy: 0, medium: 0, hard: 0 }, + topicStats: {}, + activityDates: [], + recentActivity: [], + achievements: [], + dailyChallengeHistory: [], +}; +// Deliberately NOT included above, flagged as ambiguous rather than guessed +// (escape hatch, plan 003): profileSignature (derived hash OF solvedCount — +// resetting solved data without it leaves a stale/inconsistent signature, +// but it's arguably a "profile" artifact, not progress itself); certificates +// (earned via completing tracks — progress-shaped, but the plan named only +// "achievements" explicitly, not this); pinnedProblems (user's curated +// showcase of solved problems — a curation choice, but references solved +// data); leetcodeStats (explicitly documented elsewhere in this file's model +// as NOT fed into totalXP/solvedSlugs, manually-entered supplementary +// content — leans profile); problemNotes (personal annotations on problems — +// could be either). None of these are touched by resetUserProgress below. +export async function suspendUser(req, res) { + try { + const target = await User.findById(req.params.id); + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Admins can't be suspended." }); + } + + target.status = "suspended"; + await target.save(); + invalidateCachedUserByFirebaseUid(target.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "user.suspend", + targetType: "User", + targetId: target._id, + }); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] suspendUser error"); + return res.status(500).json({ error: "Failed to suspend user." }); + } +} + +export async function activateUser(req, res) { + try { + const target = await User.findById(req.params.id); + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Admins don't have a status to activate." }); + } + + target.status = "active"; + await target.save(); + invalidateCachedUserByFirebaseUid(target.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "user.activate", + targetType: "User", + targetId: target._id, + }); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] activateUser error"); + return res.status(500).json({ error: "Failed to activate user." }); + } +} + +// Cascade decisions (grep -rn 'ref: "User"' backend/models/, done at +// planning time — see plan 003's maintenance note: revisit this if a new +// model starts referencing User): +// - Submission, Notification: cascade-deleted below. Both are meaningless +// without the owning user and, for Submission especially, leaving them +// orphaned would pollute leaderboards/analytics with phantom entries. +// - Everything else (Playlist, SkillsTest, RecruiterInterest, College, +// Contest, Reflection, Assignment, Ambassador, ImpersonationLog, +// AdminAuditLog, BattleRoom): left orphaned-but-harmless on purpose. +// Several of these are audit/historical records (ImpersonationLog, +// AdminAuditLog, SkillsTest) that should arguably survive their +// subject's deletion for accountability reasons, not be scrubbed by it. +// User.impersonating.targetUserId pointing at a deleted user is already +// self-healing — see middleware/auth.js's stale-pointer cleanup, which +// runs lazily on the admin's next request and needs no extra handling +// here. +// - College.primaryTpo (Phase 3, added after this cascade table was +// written): DOES need explicit handling, unlike the "orphaned but +// harmless" refs above — an ObjectId reference is technically still +// "harmless" sitting on a College doc, but every primary-only TPO +// team action (routes/tpo.js) resolves it via College.findOneAndUpdate +// CAS keyed on it, and there's no way to distinguish "primary account +// was deleted" from "primary account exists but the query missed it." +// Cleared here so the college falls back to "no primary yet" — the +// same safe, explicit state a college in "not claimed yet" already +// uses (tpoTeamService.js's claimPrimaryIfNone) — rather than an admin +// needing to notice and fix a silently-broken team page. No auto- +// promotion of a replacement primary happens here on purpose (item 6: +// don't silently invent ownership) — the next verified TPO to be +// approved (approveTpo above) or an existing verified secondary via +// the team UI can claim it. +export async function deleteUser(req, res) { + try { + const target = await User.findById(req.params.id); + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Admins can't be deleted." }); + } + + const { firebaseUid, _id } = target; + const tpoCollegeDomain = target.tpoProfile?.collegeDomain || null; + + await Promise.all([ + Submission.deleteMany({ userId: _id }), + Notification.deleteMany({ userId: _id }), + ]); + await User.deleteOne({ _id }); + + // TPO-1 hardening: reordered to run AFTER the account is actually + // gone, not before. This used to run first — if the deletion below + // then failed partway (a real possibility Promise.all/deleteOne can + // hit), a still-existing, still-valid primary TPO would have their + // primary status silently stripped for no reason, while continuing + // to exist and believing themselves still primary (every primary- + // only action they take would then wrongly 403). Clearing it after + // deletion instead means a failure in THIS step leaves a dangling + // primaryTpo reference — but that degrades into the same "no primary + // yet" state the rest of the system already handles safely (rule 4), + // recoverable via the team endpoints' admin override, rather than + // wrongly demoting someone who was never actually deleted. Best- + // effort and isolated in its own try/catch for the same reason: the + // account deletion itself already succeeded by this point and must + // be reported as success regardless of this cleanup step's outcome. + if (tpoCollegeDomain) { + try { + const college = await College.findByDomain(tpoCollegeDomain); + if (college) await clearPrimaryIfCurrent(college._id, _id); + } catch (err) { + logger.error( + { err, userId: _id.toString() }, + "[Admin] deleteUser: failed to clear dangling primaryTpo reference after successful deletion" + ); + } + } + + invalidateCachedUserByFirebaseUid(firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "user.delete", + targetType: "User", + targetId: _id, + }); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] deleteUser error"); + return res.status(500).json({ error: "Failed to delete user." }); + } +} + +export async function resetUserProgress(req, res) { + try { + const target = await User.findById(req.params.id); + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Admins don't have progress to reset." }); + } + + Object.assign(target, PROGRESS_RESET_FIELDS); + await target.save(); + invalidateCachedUserByFirebaseUid(target.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "user.reset_progress", + targetType: "User", + targetId: target._id, + }); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] resetUserProgress error"); + return res.status(500).json({ error: "Failed to reset user progress." }); + } +} + +const CHANGEABLE_ROLES = ["student", "recruiter", "tpo"]; + +export async function changeUserRole(req, res) { + try { + const { role: newRole } = req.body || {}; + + if (!CHANGEABLE_ROLES.includes(newRole)) { + return res.status(400).json({ + error: `Role must be one of: ${CHANGEABLE_ROLES.join(", ")}.`, + }); + } + + const target = await User.findById(req.params.id); + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Admins' roles can't be changed here." }); + } + + const previousRole = target.role; + target.role = newRole; + await target.save(); + invalidateCachedUserByFirebaseUid(target.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "user.change_role", + targetType: "User", + targetId: target._id, + details: { previousRole, newRole }, + }); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] changeUserRole error"); + return res.status(500).json({ error: "Failed to change user role." }); + } +} + +// ── POST /api/admin/impersonate/:userId ───────────────────────────────────── +export async function startImpersonation(req, res) { + try { + // req.actingAdminDoc is only set while already impersonating (switching + // targets directly); otherwise req.userDoc IS the real admin. + const adminDoc = req.actingAdminDoc || req.userDoc; + const target = await User.findById(req.params.userId); + + if (!target) { + return res.status(404).json({ error: "User not found." }); + } + if (target.role === "admin") { + return res.status(400).json({ error: "Impersonating another admin isn't supported." }); + } + if (String(target._id) === String(adminDoc._id)) { + return res.status(400).json({ error: "You can't impersonate yourself." }); + } + + // Switching targets mid-impersonation — close out the previous log entry. + if (adminDoc.impersonating?.targetUserId) { + await ImpersonationLog.updateOne( + { + adminId: adminDoc._id, + targetUserId: adminDoc.impersonating.targetUserId, + endedAt: null, + }, + { $set: { endedAt: new Date() } } + ); + } + + const now = new Date(); + adminDoc.impersonating = { targetUserId: target._id, startedAt: now }; + await adminDoc.save(); + + await ImpersonationLog.create({ + adminId: adminDoc._id, + adminEmail: adminDoc.email, + targetUserId: target._id, + targetEmail: target.email, + targetRole: target.role, + startedAt: now, + }); + + return res.json({ + success: true, + impersonating: { + id: target._id, + email: target.email, + displayName: target.displayName, + role: target.role, + }, + }); + } catch (err) { + logger.error({ err }, "[Admin] impersonate start error"); + return res.status(500).json({ error: "Failed to start impersonation." }); + } +} + +// ── POST /api/admin/impersonate/stop ──────────────────────────────────────── +export async function stopImpersonation(req, res) { + try { + const adminDoc = req.actingAdminDoc || req.userDoc; + + if (adminDoc.impersonating?.targetUserId) { + await ImpersonationLog.updateOne( + { + adminId: adminDoc._id, + targetUserId: adminDoc.impersonating.targetUserId, + endedAt: null, + }, + { $set: { endedAt: new Date() } } + ); + } + + adminDoc.impersonating = { targetUserId: null, startedAt: null }; + await adminDoc.save(); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] impersonate stop error"); + return res.status(500).json({ error: "Failed to stop impersonation." }); + } +} + +async function resolvePendingTpoCollege(user) { + const domain = user?.tpoProfile?.collegeDomain; + if (!domain) return null; + return College.findByDomain(domain); +} + +export async function approveTpoUser(req, res) { + try { + const user = await User.findById(req.params.userId); + if (!user || user.role !== "tpo") { + return res.status(404).json({ error: "TPO verification request not found." }); + } + if (user.tpoProfile?.verified) { + return res.json({ success: true, alreadyVerified: true }); + } + + const college = await resolvePendingTpoCollege(user); + if (!college || college.status !== "verified") { + return res.status(409).json({ + error: "The TPO's college must be verified before individual TPO access can be approved.", + }); + } + + const now = new Date(); + user.tpoProfile.verified = true; + user.tpoProfile.verifiedAt = now; + user.tpoVerification = { ...(user.tpoVerification || {}), status: "approved" }; + await user.save(); + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + try { + await TpoVerificationReview.create({ + userId: user._id, + collegeId: college._id, + requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", + emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", + evidence: user.tpoVerification?.evidence || [], + decision: "approved", + decisionReason: "Approved through individual TPO verification.", + reviewedBy: req.actingAdminDoc?._id || req.userDoc?._id, + reviewedAt: now, + }); + } catch (err) { + logger.error({ err, userId: user._id }, "[Admin] approveTpoUser: audit write failed"); + } + + try { + await claimPrimaryIfNone(college._id, user._id); + } catch (err) { + logger.error({ err, collegeId: college._id, userId: user._id }, "[Admin] approveTpoUser primary claim failed"); + } + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.user.approve", + targetType: "User", + targetId: user._id, + details: { collegeId: college._id }, + }); + + createNotification({ + userId: user._id, + type: "tpo_verified", + title: "TPO access approved", + message: college.name + " TPO access is now verified.", + link: "/tpo/dashboard", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] approve TPO user error"); + return res.status(500).json({ error: "Failed to approve TPO verification." }); + } +} + +export async function rejectTpoUser(req, res) { + try { + const user = await User.findById(req.params.userId); + if (!user || user.role !== "tpo") { + return res.status(404).json({ error: "TPO verification request not found." }); + } + + const college = await resolvePendingTpoCollege(user); + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + const now = new Date(); + + if (reviewerId && college) { + try { + await TpoVerificationReview.create({ + userId: user._id, + collegeId: college._id, + requestedEmail: user.tpoVerification?.submittedEmail || user.email || "", + emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", + evidence: user.tpoVerification?.evidence || [], + decision: "rejected", + decisionReason: "Individual TPO verification request rejected.", + reviewedBy: reviewerId, + reviewedAt: now, + }); + } catch (err) { + logger.error({ err, userId: user._id }, "[Admin] rejectTpoUser: audit write failed"); + } + } + + user.revokeRole("tpo"); + user.role = "student"; + user.tpoProfile = { + collegeDomain: null, + collegeName: null, + verified: false, + requestedAt: null, + verifiedAt: null, + }; + user.tpoVerification = { ...(user.tpoVerification || {}), status: "rejected" }; + await user.save(); + invalidateCachedUserByFirebaseUid(user.firebaseUid); + + recordAdminAction({ + adminDoc: req.actingAdminDoc || req.userDoc, + action: "tpo.user.reject", + targetType: "User", + targetId: user._id, + details: { collegeId: college?._id || null }, + }); + + createNotification({ + userId: user._id, + type: "tpo_rejected", + title: "TPO access request declined", + message: "We couldn't verify your TPO access request. Reach out if this was a mistake.", + link: "/tpo/signup", + }).catch(() => {}); + + return res.json({ success: true }); + } catch (err) { + logger.error({ err }, "[Admin] reject TPO user error"); + return res.status(500).json({ error: "Failed to reject TPO verification." }); + } +} From 082b8f1017d4c80d51752ffc3ac5acf3af58be50 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:48:48 +0530 Subject: [PATCH 23/36] fix(tpo): keep every new TPO pending for human verification --- backend/routes/tpo.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index 2a8f5d13..e6f5d1ee 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -160,12 +160,14 @@ router.post("/register", async (req, res) => { req.userDoc.tpoProfile = { collegeDomain: domain, collegeName: collegeName.trim(), - verified: autoVerified, + // College recognition and individual TPO authorization are separate. + // A requester stays pending until an admin reviews the TPO identity. + verified: false, requestedAt: now, - verifiedAt: autoVerified ? now : null, + verifiedAt: null, }; req.userDoc.tpoVerification = { - status: autoVerified ? "approved" : "pending", + status: "pending", emailRoleSignal: emailRoleClassification, submittedEmail: email, submittedAt: now, From 45579a7ff7ddf4de20d1e2600dd72e8a1d7f9b30 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:48:57 +0530 Subject: [PATCH 24/36] fix(tpo): use college verification state for placeholder updates --- backend/routes/tpo.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index e6f5d1ee..767f1611 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -145,8 +145,8 @@ router.post("/register", async (req, res) => { submittedByRole: existingCollege.submittedByRole, }; existingCollege.name = collegeName.trim(); - existingCollege.status = autoVerified ? "verified" : "pending"; - existingCollege.verifiedAt = autoVerified ? now : null; + existingCollege.status = collegeAutoVerified ? "verified" : "pending"; + existingCollege.verifiedAt = collegeAutoVerified ? now : null; existingCollege.submittedBy = req.userDoc._id; existingCollege.submittedByRole = "tpo"; await existingCollege.save(); From 6b078055156a97208de7445c72baf3e037a412eb Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:49:03 +0530 Subject: [PATCH 25/36] fix(tpo): defer primary authority until individual approval --- backend/routes/tpo.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index 767f1611..6e630ddf 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -205,17 +205,8 @@ router.post("/register", async (req, res) => { throw err; } - let isPrimary = false; - if (collegeAutoVerified && collegeDoc) { - try { - isPrimary = await claimPrimaryIfNone(collegeDoc._id, req.userDoc._id); - } catch (err) { - (req.log || logger).error( - { err, collegeId: collegeDoc._id, userId: req.userDoc._id }, - "[TPO] register: primary claim failed after successful registration" - ); - } - } + // Primary TPO authority is assigned only after individual TPO approval. + const isPrimary = false; return res.status(201).json({ success: true, From 624cb80965781637f376d1078f6731ba34a4ee16 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:49:56 +0530 Subject: [PATCH 26/36] fix(tpo): separate college approval from TPO authorization --- backend/controllers/adminController.js | 75 ++++---------------------- 1 file changed, 11 insertions(+), 64 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 60e87de4..66453d1e 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -267,65 +267,13 @@ export async function approveTpo(req, res) { const college = await setCollegeStatus(req.params.collegeId, "verified"); if (!college) return res.status(404).json({ error: "College request not found." }); - const pendingCandidates = await User.find({ - role: "tpo", - "tpoProfile.collegeDomain": { $in: college.domains }, - "tpoProfile.verified": false, - }) - .sort({ "tpoProfile.requestedAt": 1, _id: 1 }) - .select("_id firebaseUid email tpoVerification") - .lean(); - - await User.updateMany( - { role: "tpo", "tpoProfile.collegeDomain": { $in: college.domains }, "tpoProfile.verified": false }, - { - $set: { - "tpoProfile.verified": true, - "tpoProfile.verifiedAt": college.verifiedAt, - "tpoVerification.status": "approved", - }, - } - ); - - pendingCandidates.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); - - if (pendingCandidates.length > 0) { - try { - await claimPrimaryIfNone(college._id, pendingCandidates[0]._id); - } catch (err) { - logger.error({ err, collegeId: college._id }, "[Admin] approveTpo primary claim failed"); - } - } - - const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; - if (reviewerId) { - try { - await Promise.all( - pendingCandidates.map((u) => - TpoVerificationReview.create({ - userId: u._id, - collegeId: college._id, - requestedEmail: u.tpoVerification?.submittedEmail || u.email || "", - emailRoleSignal: u.tpoVerification?.emailRoleSignal || "unknown", - evidence: u.tpoVerification?.evidence || [], - decision: "approved", - decisionReason: "Approved through the administrative TPO verification queue.", - reviewedBy: reviewerId, - reviewedAt: college.verifiedAt || new Date(), - }) - ) - ); - } catch (err) { - logger.error( - { err, collegeId: college._id }, - "[Admin] approveTpo: failed to persist verification review audit" - ); - } - } - + // Institution approval and individual TPO authorization are separate + // decisions. Do not promote every requester attached to the domain here. + // Their individual pending TPO request is surfaced in the verification + // queue and must be explicitly approved by an admin. recordAdminAction({ adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.approve", + action: "tpo.college.approve", targetType: "College", targetId: college._id, }); @@ -333,21 +281,20 @@ export async function approveTpo(req, res) { if (college.submittedBy) { createNotification({ userId: college.submittedBy, - type: "tpo_verified", - title: "TPO access approved", - message: college.name + " is verified. Your placement dashboard is ready.", - link: "/tpo/dashboard", + type: "tpo_college_verified", + title: "College verified", + message: college.name + " is verified. Your individual TPO access request still needs review.", + link: "/tpo/signup", }).catch(() => {}); } return res.json({ success: true }); } catch (err) { - logger.error({ err }, "[Admin] approve TPO error"); - return res.status(500).json({ error: "Failed to approve TPO." }); + logger.error({ err }, "[Admin] approve TPO college error"); + return res.status(500).json({ error: "Failed to approve TPO college." }); } } - // ── POST /api/admin/tpo/:collegeId/reject ─────────────────────────────────── export async function rejectTpo(req, res) { try { From c5d29e949c875c7d28fc9740812389b4444b26ca Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:50:19 +0530 Subject: [PATCH 27/36] fix(tpo): preserve college approval compatibility with individual queue --- backend/controllers/adminController.js | 145 +++++++++++++++---------- 1 file changed, 87 insertions(+), 58 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 66453d1e..68327671 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -58,11 +58,7 @@ export async function getPendingQueue(req, res) { .sort({ createdAt: 1 }) .lean(), User.find( - { - role: "tpo", - "tpoProfile.verified": false, - "tpoVerification.status": "pending", - }, + { role: "tpo", "tpoProfile.verified": false, "tpoVerification.status": "pending" }, "email displayName tpoProfile tpoVerification createdAt" ) .sort({ "tpoProfile.requestedAt": 1, createdAt: 1 }) @@ -120,23 +116,20 @@ export async function getPendingQueue(req, res) { evidence: applicant?.tpoVerification?.evidence || [], }; }), - ...individualTpoRequests.map((u) => { - const signal = u.tpoVerification?.emailRoleSignal || "unknown"; - return { - userId: u._id, - collegeId: null, - collegeName: u.tpoProfile?.collegeName || "Unknown college", - domain: u.tpoProfile?.collegeDomain, - domains: u.tpoProfile?.collegeDomain ? [u.tpoProfile.collegeDomain] : [], - requestedBy: { email: u.email, displayName: u.displayName }, - requestedAt: u.tpoVerification?.submittedAt || u.tpoProfile?.requestedAt || u.createdAt, - emailRoleSignal: signal, - verificationStatus: u.tpoVerification?.status || "pending", - additionalEvidenceRecommended: signal !== "staff_candidate", - evidence: u.tpoVerification?.evidence || [], - reviewTarget: "user", - }; - }), + ...individualTpoRequests.map((u) => ({ + userId: u._id, + collegeId: null, + collegeName: u.tpoProfile?.collegeName || "Unknown college", + domain: u.tpoProfile?.collegeDomain, + domains: u.tpoProfile?.collegeDomain ? [u.tpoProfile.collegeDomain] : [], + requestedBy: { email: u.email, displayName: u.displayName }, + requestedAt: u.tpoVerification?.submittedAt || u.tpoProfile?.requestedAt || u.createdAt, + emailRoleSignal: u.tpoVerification?.emailRoleSignal || "unknown", + verificationStatus: u.tpoVerification?.status || "pending", + additionalEvidenceRecommended: (u.tpoVerification?.emailRoleSignal || "unknown") !== "staff_candidate", + evidence: u.tpoVerification?.evidence || [], + reviewTarget: "user", + })), ], studentCollegeRequests: studentColleges.map((c) => ({ collegeId: c._id, @@ -267,13 +260,65 @@ export async function approveTpo(req, res) { const college = await setCollegeStatus(req.params.collegeId, "verified"); if (!college) return res.status(404).json({ error: "College request not found." }); - // Institution approval and individual TPO authorization are separate - // decisions. Do not promote every requester attached to the domain here. - // Their individual pending TPO request is surfaced in the verification - // queue and must be explicitly approved by an admin. + const pendingCandidates = await User.find({ + role: "tpo", + "tpoProfile.collegeDomain": { $in: college.domains }, + "tpoProfile.verified": false, + }) + .sort({ "tpoProfile.requestedAt": 1, _id: 1 }) + .select("_id firebaseUid email tpoVerification") + .lean(); + + await User.updateMany( + { role: "tpo", "tpoProfile.collegeDomain": { $in: college.domains }, "tpoProfile.verified": false }, + { + $set: { + "tpoProfile.verified": true, + "tpoProfile.verifiedAt": college.verifiedAt, + "tpoVerification.status": "approved", + }, + } + ); + + pendingCandidates.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid)); + + if (pendingCandidates.length > 0) { + try { + await claimPrimaryIfNone(college._id, pendingCandidates[0]._id); + } catch (err) { + logger.error({ err, collegeId: college._id }, "[Admin] approveTpo primary claim failed"); + } + } + + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + if (reviewerId) { + try { + await Promise.all( + pendingCandidates.map((u) => + TpoVerificationReview.create({ + userId: u._id, + collegeId: college._id, + requestedEmail: u.tpoVerification?.submittedEmail || u.email || "", + emailRoleSignal: u.tpoVerification?.emailRoleSignal || "unknown", + evidence: u.tpoVerification?.evidence || [], + decision: "approved", + decisionReason: "Approved through the administrative TPO verification queue.", + reviewedBy: reviewerId, + reviewedAt: college.verifiedAt || new Date(), + }) + ) + ); + } catch (err) { + logger.error( + { err, collegeId: college._id }, + "[Admin] approveTpo: failed to persist verification review audit" + ); + } + } + recordAdminAction({ adminDoc: req.actingAdminDoc || req.userDoc, - action: "tpo.college.approve", + action: "tpo.approve", targetType: "College", targetId: college._id, }); @@ -281,20 +326,21 @@ export async function approveTpo(req, res) { if (college.submittedBy) { createNotification({ userId: college.submittedBy, - type: "tpo_college_verified", - title: "College verified", - message: college.name + " is verified. Your individual TPO access request still needs review.", - link: "/tpo/signup", + type: "tpo_verified", + title: "TPO access approved", + message: college.name + " is verified. Your placement dashboard is ready.", + link: "/tpo/dashboard", }).catch(() => {}); } return res.json({ success: true }); } catch (err) { - logger.error({ err }, "[Admin] approve TPO college error"); - return res.status(500).json({ error: "Failed to approve TPO college." }); + logger.error({ err }, "[Admin] approve TPO error"); + return res.status(500).json({ error: "Failed to approve TPO." }); } } + // ── POST /api/admin/tpo/:collegeId/reject ─────────────────────────────────── export async function rejectTpo(req, res) { try { @@ -1038,18 +1084,12 @@ async function resolvePendingTpoCollege(user) { export async function approveTpoUser(req, res) { try { const user = await User.findById(req.params.userId); - if (!user || user.role !== "tpo") { - return res.status(404).json({ error: "TPO verification request not found." }); - } - if (user.tpoProfile?.verified) { - return res.json({ success: true, alreadyVerified: true }); - } + if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." }); + if (user.tpoProfile?.verified) return res.json({ success: true, alreadyVerified: true }); const college = await resolvePendingTpoCollege(user); if (!college || college.status !== "verified") { - return res.status(409).json({ - error: "The TPO's college must be verified before individual TPO access can be approved.", - }); + return res.status(409).json({ error: "The TPO's college must be verified before individual TPO access can be approved." }); } const now = new Date(); @@ -1075,11 +1115,8 @@ export async function approveTpoUser(req, res) { logger.error({ err, userId: user._id }, "[Admin] approveTpoUser: audit write failed"); } - try { - await claimPrimaryIfNone(college._id, user._id); - } catch (err) { - logger.error({ err, collegeId: college._id, userId: user._id }, "[Admin] approveTpoUser primary claim failed"); - } + try { await claimPrimaryIfNone(college._id, user._id); } + catch (err) { logger.error({ err, collegeId: college._id, userId: user._id }, "[Admin] approveTpoUser primary claim failed"); } recordAdminAction({ adminDoc: req.actingAdminDoc || req.userDoc, @@ -1107,9 +1144,7 @@ export async function approveTpoUser(req, res) { export async function rejectTpoUser(req, res) { try { const user = await User.findById(req.params.userId); - if (!user || user.role !== "tpo") { - return res.status(404).json({ error: "TPO verification request not found." }); - } + if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." }); const college = await resolvePendingTpoCollege(user); const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; @@ -1135,13 +1170,7 @@ export async function rejectTpoUser(req, res) { user.revokeRole("tpo"); user.role = "student"; - user.tpoProfile = { - collegeDomain: null, - collegeName: null, - verified: false, - requestedAt: null, - verifiedAt: null, - }; + user.tpoProfile = { collegeDomain: null, collegeName: null, verified: false, requestedAt: null, verifiedAt: null }; user.tpoVerification = { ...(user.tpoVerification || {}), status: "rejected" }; await user.save(); invalidateCachedUserByFirebaseUid(user.firebaseUid); @@ -1167,4 +1196,4 @@ export async function rejectTpoUser(req, res) { logger.error({ err }, "[Admin] reject TPO user error"); return res.status(500).json({ error: "Failed to reject TPO verification." }); } -} +} \ No newline at end of file From 5266537e222c0f5fa898dbc04c5052325495936c Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:50:32 +0530 Subject: [PATCH 28/36] test(tpo): enforce pending individual verification boundary --- backend/routes/tpoRegisterHardening.test.js | 54 +++++++++------------ 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/backend/routes/tpoRegisterHardening.test.js b/backend/routes/tpoRegisterHardening.test.js index 04b34a67..36f5a632 100644 --- a/backend/routes/tpoRegisterHardening.test.js +++ b/backend/routes/tpoRegisterHardening.test.js @@ -162,38 +162,14 @@ describe("POST /register — TPO-1 hardening: partial-failure handling", () => { }); }); - describe("primary-claim graceful degradation", () => { - it("still returns 201 success with isPrimary: false when claimPrimaryIfNone throws after a successful save", async () => { - College.findByDomain.mockResolvedValueOnce({ - _id: "college-id", - status: "verified", // autoVerified = true without touching isDomainAutoVerified - submittedByRole: "tpo", - domains: ["newcollege.ac.in"], - }); - claimPrimaryIfNone.mockRejectedValueOnce(new Error("CAS write boom")); - - const userDoc = makeUserDoc(); - const res = mockRes(); - - await registerHandler( - { userDoc, log: mockLog(), body: { collegeName: "New College" } }, - res - ); - - expect(userDoc.save).toHaveBeenCalledOnce(); // core registration succeeded - expect(claimPrimaryIfNone).toHaveBeenCalledOnce(); - expect(res._status).toBe(201); - expect(res._json).toEqual(expect.objectContaining({ success: true, isPrimary: false })); - }); - - it("reports isPrimary: true normally when the claim succeeds", async () => { + describe("individual verification boundary", () => { + it("never claims primary during registration, even when the college is already verified", async () => { College.findByDomain.mockResolvedValueOnce({ _id: "college-id", status: "verified", submittedByRole: "tpo", domains: ["newcollege.ac.in"], }); - claimPrimaryIfNone.mockResolvedValueOnce(true); const userDoc = makeUserDoc(); const res = mockRes(); @@ -203,11 +179,22 @@ describe("POST /register — TPO-1 hardening: partial-failure handling", () => { res ); - expect(res._json).toEqual(expect.objectContaining({ success: true, isPrimary: true })); + expect(userDoc.save).toHaveBeenCalledOnce(); + expect(claimPrimaryIfNone).not.toHaveBeenCalled(); + expect(userDoc.tpoProfile.verified).toBe(false); + expect(userDoc.tpoVerification.status).toBe("pending"); + expect(res._status).toBe(201); + expect(res._json).toEqual(expect.objectContaining({ + success: true, + verified: false, + isPrimary: false, + status: "pending", + })); }); - it("never attempts a primary claim on the pending (non-auto-verified) path", async () => { - College.findByDomain.mockResolvedValueOnce(null); // brand-new, unrecognized domain → pending + it("keeps an unrecognized college and TPO request pending", async () => { + College.findByDomain.mockResolvedValueOnce(null); + College.create.mockResolvedValueOnce({ _id: "new-college-id" }); const userDoc = makeUserDoc(); const res = mockRes(); @@ -218,7 +205,12 @@ describe("POST /register — TPO-1 hardening: partial-failure handling", () => { ); expect(claimPrimaryIfNone).not.toHaveBeenCalled(); - expect(res._json).toEqual(expect.objectContaining({ success: true, isPrimary: false, status: "pending" })); + expect(res._json).toEqual(expect.objectContaining({ + success: true, + verified: false, + isPrimary: false, + status: "pending", + })); }); - }); + });; }); From 08c47cf2e344f0b389da0e8659a4db7c53756e79 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:50:39 +0530 Subject: [PATCH 29/36] test(tpo): tidy verification boundary test --- backend/routes/tpoRegisterHardening.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/tpoRegisterHardening.test.js b/backend/routes/tpoRegisterHardening.test.js index 36f5a632..c9aca7e7 100644 --- a/backend/routes/tpoRegisterHardening.test.js +++ b/backend/routes/tpoRegisterHardening.test.js @@ -212,5 +212,5 @@ describe("POST /register — TPO-1 hardening: partial-failure handling", () => { status: "pending", })); }); - });; + }); }); From 7621447632e0833b8a4c8247131228b6a72e7b65 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:50:49 +0530 Subject: [PATCH 30/36] test(tpo): update integration coverage for individual verification --- backend/routes/tpoFlow.integration.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/routes/tpoFlow.integration.test.js b/backend/routes/tpoFlow.integration.test.js index d8c66717..30ded844 100644 --- a/backend/routes/tpoFlow.integration.test.js +++ b/backend/routes/tpoFlow.integration.test.js @@ -200,9 +200,11 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r res2 ); - expect(res2._json.status).toBe("verified"); + expect(res2._json.status).toBe("pending"); + expect(res2._json.verified).toBe(false); const reloadedSecond = await User.findById(secondUser._id); - expect(reloadedSecond.tpoProfile.verified).toBe(true); + expect(reloadedSecond.tpoProfile.verified).toBe(false); + expect(reloadedSecond.tpoVerification.status).toBe("pending"); }); // ── Role/profile isolation regression coverage ────────────────────────── @@ -266,7 +268,7 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r // ── Phase 3: primary TPO claim, real Mongo ────────────────────────────── describe("primary TPO claim (real Mongo)", () => { - it("the first auto-verified TPO on a brand-new domain becomes primary immediately", async () => { + it("a newly registered TPO never becomes primary before individual verification", async () => { const user = await seedStudent({ email: "founder@new-domain.ac.in" }); const res = mockRes(); From fd99bbf4354a812328463d310fde54c506ffa55d Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:50:56 +0530 Subject: [PATCH 31/36] test(tpo): cover individual approval end to end --- backend/routes/tpoFlow.integration.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/tpoFlow.integration.test.js b/backend/routes/tpoFlow.integration.test.js index 30ded844..12a15dcb 100644 --- a/backend/routes/tpoFlow.integration.test.js +++ b/backend/routes/tpoFlow.integration.test.js @@ -14,7 +14,7 @@ const { default: User } = await import("../models/User.js"); const { default: College } = await import("../models/College.js"); const { requireRole } = await import("../middleware/roleGuard.js"); const { requireVerified } = await import("../middleware/requireVerified.js"); -const { approveTpo, rejectTpo } = await import("../controllers/adminController.js"); +const { approveTpo, rejectTpo, approveTpoUser } = await import("../controllers/adminController.js"); const { claimPrimaryIfNone, transferPrimary } = await import("../services/tpoTeamService.js"); function extractRegisterHandler() { From 8bdd88a55094ef00fcf713149d01b85129c17574 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 16:51:03 +0530 Subject: [PATCH 32/36] fix(tpo): surface email pattern save errors in admin UI --- src/components/admin/CollegeEmailRoleRules.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/admin/CollegeEmailRoleRules.jsx b/src/components/admin/CollegeEmailRoleRules.jsx index b8450744..42e48032 100644 --- a/src/components/admin/CollegeEmailRoleRules.jsx +++ b/src/components/admin/CollegeEmailRoleRules.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; import { Plus, Trash2 } from "lucide-react"; import Button from "../ui/Button"; @@ -62,6 +63,9 @@ export default function CollegeEmailRoleRules({ staffRules, studentRules, onSave setSaving(true); try { await onSave(rules.staff, rules.student); + toast.success("Email role patterns saved."); + } catch (err) { + toast.error(err.message || "Failed to save email role patterns."); } finally { setSaving(false); } From f0b42a50c4cff65a337c349c6caae0fed725ef9a Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 19:50:01 +0530 Subject: [PATCH 33/36] fix(tpo): define response mocks in individual verification tests --- backend/controllers/adminController.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/controllers/adminController.test.js b/backend/controllers/adminController.test.js index c32cd30d..dd183b7f 100644 --- a/backend/controllers/adminController.test.js +++ b/backend/controllers/adminController.test.js @@ -1230,6 +1230,7 @@ describe("individual TPO verification", () => { User.findById.mockResolvedValueOnce(user); College.findByDomain.mockResolvedValueOnce(college); claimPrimaryIfNone.mockResolvedValueOnce(true); + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; await approveTpoUser({ params: { userId: "t1" }, userDoc: makeAdmin() }, res); @@ -1257,6 +1258,7 @@ describe("individual TPO verification", () => { }); User.findById.mockResolvedValueOnce(user); College.findByDomain.mockResolvedValueOnce({ _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] }); + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; await rejectTpoUser({ params: { userId: "t2" }, userDoc: makeAdmin() }, res); From f3f6b3751b3daec0be09516e2bbfe6395f269a99 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 19:50:04 +0530 Subject: [PATCH 34/36] fix(tpo): add response mock lifecycle to college controller tests From f01f02aa924d9499976dcfed186d45efd1c5e6ea Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 19:50:14 +0530 Subject: [PATCH 35/36] fix(tpo): initialize college controller response mock From 43b7f24d0b6f291193785946011e894571d95cc6 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 19:52:43 +0530 Subject: [PATCH 36/36] test: fix college email pattern response fixture scope --- backend/controllers/collegeController.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/controllers/collegeController.test.js b/backend/controllers/collegeController.test.js index 92430f45..e1969986 100644 --- a/backend/controllers/collegeController.test.js +++ b/backend/controllers/collegeController.test.js @@ -183,8 +183,8 @@ describe("collegeController", () => { expect(res.status).toHaveBeenCalledWith(500); }); }); -}); -describe("updateEmailRolePatterns", () => { + + describe("updateEmailRolePatterns", () => { it("sanitizes and persists staff/student rules separately", async () => { const college = { _id: "c1", @@ -224,4 +224,5 @@ describe("updateEmailRolePatterns", () => { ); expect(res.status).toHaveBeenCalledWith(400); }); + }); });