Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d268920
feat(tpo): add college-specific email role pattern configuration
SSVP-debug Sep 26, 2026
5e35490
feat(tpo): expose college email role pattern admin endpoint
SSVP-debug Sep 26, 2026
3cab913
test(tpo): cover college email role pattern configuration
SSVP-debug Sep 26, 2026
e638701
feat(tpo): add college email role pattern editor
SSVP-debug Sep 26, 2026
aa886b5
feat(tpo): wire college email role pattern updates
SSVP-debug Sep 26, 2026
08f6823
feat(tpo): add college email role pattern controls
SSVP-debug Sep 26, 2026
adfed6c
feat(tpo): expose email role configuration in college admin
SSVP-debug Sep 26, 2026
c0a26c9
fix(tpo): separate college trust from individual TPO verification
SSVP-debug Sep 26, 2026
7f1e6ac
feat(tpo): add individual TPO verification decisions
SSVP-debug Sep 26, 2026
af91359
feat(tpo): expose individual TPO verification routes
SSVP-debug Sep 26, 2026
c91df08
feat(tpo): surface individual pending TPO verification requests
SSVP-debug Sep 26, 2026
ece5c93
fix(tpo): preserve admin controller and add individual verification q…
SSVP-debug Sep 26, 2026
c2fa29d
fix(tpo): remove duplicate pending verification query block
SSVP-debug Sep 26, 2026
b2118d2
fix(tpo): remove duplicate pending TPO query
SSVP-debug Sep 26, 2026
d1f7363
feat(tpo): support individual TPO verification actions in admin queue
SSVP-debug Sep 26, 2026
d7f6af3
chore(tpo): keep TPO queue action wiring compatible
SSVP-debug Sep 26, 2026
5edac13
feat(tpo): allow queue rows to carry individual review targets
SSVP-debug Sep 26, 2026
ff1122a
feat(tpo): route individual TPO queue rows to user review
SSVP-debug Sep 26, 2026
c683a01
test(tpo): cover individual TPO verification decisions
SSVP-debug Sep 26, 2026
fca9e1b
test(tpo): mock individual pending TPO queue in admin tests
SSVP-debug Sep 26, 2026
39df662
docs(tpo): document institutional email-role verification flow
SSVP-debug Sep 26, 2026
d331bd0
fix(tpo): restore admin controller and apply individual verification …
SSVP-debug Sep 26, 2026
082b8f1
fix(tpo): keep every new TPO pending for human verification
SSVP-debug Sep 26, 2026
45579a7
fix(tpo): use college verification state for placeholder updates
SSVP-debug Sep 26, 2026
6b07805
fix(tpo): defer primary authority until individual approval
SSVP-debug Sep 26, 2026
624cb80
fix(tpo): separate college approval from TPO authorization
SSVP-debug Sep 26, 2026
c5d29e9
fix(tpo): preserve college approval compatibility with individual queue
SSVP-debug Sep 26, 2026
5266537
test(tpo): enforce pending individual verification boundary
SSVP-debug Sep 26, 2026
08c47cf
test(tpo): tidy verification boundary test
SSVP-debug Sep 26, 2026
7621447
test(tpo): update integration coverage for individual verification
SSVP-debug Sep 26, 2026
fd99bbf
test(tpo): cover individual approval end to end
SSVP-debug Sep 26, 2026
8bdd88a
fix(tpo): surface email pattern save errors in admin UI
SSVP-debug Sep 26, 2026
f0b42a5
fix(tpo): define response mocks in individual verification tests
SSVP-debug Sep 26, 2026
f3f6b37
fix(tpo): add response mock lifecycle to college controller tests
SSVP-debug Sep 26, 2026
f01f02a
fix(tpo): initialize college controller response mock
SSVP-debug Sep 26, 2026
43b7f24
test: fix college email pattern response fixture scope
SSVP-debug Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 155 additions & 3 deletions backend/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,6 +57,12 @@ 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");
Expand All @@ -66,6 +72,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"
);
Expand All @@ -80,7 +93,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;
Expand All @@ -101,7 +115,22 @@ export async function getPendingQueue(req, res) {
additionalEvidenceRecommended: signal !== "staff_candidate",
evidence: applicant?.tpoVerification?.evidence || [],
};
}),
}),
...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,
collegeName: c.name,
Expand Down Expand Up @@ -1044,4 +1073,127 @@ export async function stopImpersonation(req, res) {
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." });
}
}
71 changes: 70 additions & 1 deletion backend/controllers/adminController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ import {
rejectRecruiter,
approveTpo,
rejectTpo,
approveTpoUser,
rejectTpoUser,
approveStudentCollege,
rejectStudentCollege,
listUsers,
Expand Down Expand Up @@ -167,6 +169,7 @@ describe("adminController", () => {
},
])
);
User.find.mockReturnValueOnce(chainableQuery([]));

const req = {};
await getPendingQueue(req, res);
Expand Down Expand Up @@ -1199,4 +1202,70 @@ describe("adminController", () => {
expect(res.status).toHaveBeenCalledWith(500);
});
});
});
});

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);
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };

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"] });
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };

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();
});
});
60 changes: 59 additions & 1 deletion backend/controllers/collegeController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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." });
}
}
}

/**
* 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." });
}
}
Loading
Loading