Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
647ec4a
feat(tpo): add tpoRoleSignalService.js
SSVP-debug Sep 26, 2026
fedd016
feat(tpo): add TpoVerificationReview.js
SSVP-debug Sep 26, 2026
b90f790
feat(tpo): store college-specific role signal rules
SSVP-debug Sep 26, 2026
fb622ef
feat(tpo): persist advisory verification state
SSVP-debug Sep 26, 2026
1cf0c58
feat(tpo): capture advisory email role signal during registration
SSVP-debug Sep 26, 2026
950bf71
feat(tpo): add verification review trail to admin decisions
SSVP-debug Sep 26, 2026
c846e30
test(tpo): cover tpoRoleSignalService.test.js
SSVP-debug Sep 26, 2026
fb71479
test(tpo): cover TpoVerificationReview.test.js
SSVP-debug Sep 26, 2026
b4c7006
feat(tpo): surface verification evidence in admin queue
SSVP-debug Sep 26, 2026
dacc5b3
feat(tpo): show role signal in verification queue
SSVP-debug Sep 26, 2026
2fb15e0
docs(tpo): document advisory role signal
SSVP-debug Sep 26, 2026
23b5854
fix(tpo): resolve college before advisory role classification
SSVP-debug Sep 26, 2026
5e6767c
fix(tpo): classify email after college resolution
SSVP-debug Sep 26, 2026
cf18d9b
refactor(tpo): clean verification flow before final test
SSVP-debug Sep 26, 2026
24fc5e6
fix(tpo): restore registration flow after verification integration
SSVP-debug Sep 26, 2026
fdaea0a
fix(tpo): preserve valid local role pattern rules
SSVP-debug Sep 26, 2026
5d4838e
fix(tpo): keep existing admin queue behavior and isolate audit failures
SSVP-debug Sep 26, 2026
88d01eb
chore(tpo): preserve core admin decisions when audit write is unavail…
SSVP-debug Sep 26, 2026
f59af2f
fix: preserve TPO admin response and rejection semantics
SSVP-debug Sep 26, 2026
ee7a964
fix: sanitize empty local-prefix entries
SSVP-debug Sep 26, 2026
1604b3f
test: cover TPO review audit and rejection safety
SSVP-debug Sep 26, 2026
a44e493
fix: normalize prefixes before validation
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
182 changes: 108 additions & 74 deletions backend/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { recordAdminAction } from "../services/adminAuditLog.js";
import { invalidateCachedUserByFirebaseUid } from "../utils/userAuthCache.js";
import { logger } from "../config/logger.js";
import { claimPrimaryIfNone, clearPrimaryIfCurrent } from "../services/tpoTeamService.js";
import TpoVerificationReview from "../models/TpoVerificationReview.js";

const RECRUITER_QUEUE_FIELDS = "email displayName recruiterProfile createdAt";

Expand All @@ -53,7 +54,7 @@ export async function getPendingQueue(req, res) {
// same collection and split below by submittedByRole so the queue
// can render/label them separately.
College.find({ status: "pending" })
.populate("submittedBy", "email displayName")
.populate("submittedBy", "email displayName tpoVerification")
.sort({ createdAt: 1 })
.lean(),
]);
Expand All @@ -79,15 +80,28 @@ export async function getPendingQueue(req, res) {
companyDomain: u.recruiterProfile?.companyDomain,
requestedAt: u.createdAt,
})),
tpos: tpoColleges.map((c) => ({
collegeId: c._id,
collegeName: c.name,
domain: c.domains?.[0],
requestedBy: c.submittedBy
? { email: c.submittedBy.email, displayName: c.submittedBy.displayName }
: null,
requestedAt: c.createdAt,
})),
tpos: tpoColleges.map((c) => {
const applicant = c.submittedBy && typeof c.submittedBy === "object"
? c.submittedBy
: null;
const signal = applicant?.tpoVerification?.emailRoleSignal || "unknown";
return {
collegeId: c._id,
collegeName: c.name,
// Keep the legacy first-domain field for existing admin clients while
// exposing the complete configured domain list.
domain: c.domains?.[0],
domains: c.domains,
requestedBy: applicant
? { email: applicant.email, displayName: applicant.displayName }
: null,
requestedAt: applicant?.tpoVerification?.submittedAt || c.createdAt,
emailRoleSignal: signal,
verificationStatus: applicant?.tpoVerification?.status || "pending",
additionalEvidenceRecommended: signal !== "staff_candidate",
evidence: applicant?.tpoVerification?.evidence || [],
};
}),
studentCollegeRequests: studentColleges.map((c) => ({
collegeId: c._id,
collegeName: c.name,
Expand Down Expand Up @@ -215,64 +229,60 @@ async function setCollegeStatus(collegeId, status) {
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." });

if (!college) {
return res.status(404).json({ error: "College request not found." });
}

// ── First verified TPO becomes primary (Phase 3, item 5/6) ──────────
// This approval can verify several pending TPOs for the same domain in
// one bulk updateMany (anyone who registered while the college was
// still pending) — "first" among them is deterministic by earliest
// tpoProfile.requestedAt, read BEFORE the updateMany flips their
// verified flag (after which this same unverified-only query would
// match none of them). If the college already has a primary (e.g. an
// earlier auto-verified registration already claimed it — see
// routes/tpo.js's POST /register), claimPrimaryIfNone's CAS is a
// guaranteed no-op, so this is safe to call unconditionally rather
// than branching on college.primaryTpo here.
const pendingCandidates = await User.find({
role: "tpo",
"tpoProfile.collegeDomain": { $in: college.domains },
"tpoProfile.verified": false,
})
.sort({ "tpoProfile.requestedAt": 1 })
.select("_id firebaseUid")
.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 } }
{
$set: {
"tpoProfile.verified": true,
"tpoProfile.verifiedAt": college.verifiedAt,
"tpoVerification.status": "approved",
},
}
);

// TPO-1 closure fix: this bulk verification bypasses per-document
// save hooks (updateMany), so — unlike every other place in this file
// that flips a user's verified/authorization state (see
// approveStudentCollege below, rejectTpo, rejectRecruiter) — it never
// invalidated the short-lived auth cache (utils/userAuthCache.js) for
// the accounts it just verified. Each one would keep failing
// requireVerified with a stale "pending" userDoc until that cache
// entry's TTL expired on its own, rather than being usable
// immediately after approval.
pendingCandidates.forEach((u) => invalidateCachedUserByFirebaseUid(u.firebaseUid));

// TPO-1 hardening: isolated in its own try/catch. By this point the
// college is verified and every pending TPO has already been bulk-
// verified — the core "approve this TPO application" operation has
// already succeeded. A transient failure in this CAS write must not
// make the whole approval report a 500 back to the admin (who would
// then have no way to know the approval itself actually went
// through). Falling back to "no primary claimed this round" is a
// safe, already-supported state (rule 4: zero primary TPOs
// temporarily) — recoverable via the team endpoints' admin override,
// or automatically on this same college's next approval/registration.
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: primary claim failed after successful bulk-verification — continuing"
"[Admin] approveTpo: failed to persist verification review audit"
);
}
}
Expand All @@ -289,7 +299,7 @@ export async function approveTpo(req, res) {
userId: college.submittedBy,
type: "tpo_verified",
title: "TPO access approved",
message: `${college.name} is verified. Your placement dashboard is ready.`,
message: college.name + " is verified. Your placement dashboard is ready.",
link: "/tpo/dashboard",
}).catch(() => {});
}
Expand All @@ -301,21 +311,41 @@ export async function approveTpo(req, res) {
}
}


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

// Unlike student-submitted colleges (rejectStudentCollege below), a
// rejected TPO signup has no other purpose for the record, so the
// College doc itself is deleted here — unchanged from prior behavior.
await College.deleteOne({ _id: college._id });

recordAdminAction({
Expand All @@ -325,29 +355,32 @@ export async function rejectTpo(req, res) {
targetId: college._id,
});

if (requesterId) {
const user = await User.findById(requesterId);
if (user && user.role === "tpo") {
// Revoke the "tpo" authorization, matching rejectRecruiter's
// revokeRole above — see that comment for why.
user.revokeRole("tpo");
user.role = "student";
user.tpoProfile = {
collegeDomain: null,
collegeName: null,
verified: false,
requestedAt: null,
verifiedAt: null,
};
await user.save();
invalidateCachedUserByFirebaseUid(user.firebaseUid);
}
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.`,
message: "We couldn't verify your request for " + collegeName + ". Reach out if this was a mistake.",
link: "/tpo/signup",
}).catch(() => {});
}
Expand All @@ -359,6 +392,7 @@ export async function rejectTpo(req, res) {
}
}


// ── 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
Expand Down
34 changes: 34 additions & 0 deletions backend/controllers/adminController.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

vi.mock("../models/TpoVerificationReview.js", () => ({
default: { create: vi.fn().mockResolvedValue({}) },
}));
vi.mock("../models/College.js", () => ({
default: {
find: vi.fn(),
Expand Down Expand Up @@ -50,6 +53,7 @@ vi.mock("../config/logger.js", () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));

import TpoVerificationReview from "../models/TpoVerificationReview.js";
import College from "../models/College.js";
import User from "../models/User.js";
import ImpersonationLog from "../models/ImpersonationLog.js";
Expand Down Expand Up @@ -274,6 +278,14 @@ describe("adminController", () => {
expect(createNotification).toHaveBeenCalledWith(
expect.objectContaining({ userId: "admin-user-1", type: "tpo_verified" })
);
expect(TpoVerificationReview.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "req1",
collegeId: "c1",
decision: "approved",
reviewedBy: "admin1",
})
);
expect(res.json).toHaveBeenCalledWith({ success: true });
});

Expand Down Expand Up @@ -414,6 +426,28 @@ describe("adminController", () => {
expect(recordAdminAction).toHaveBeenCalledWith(
expect.objectContaining({ adminDoc: admin, action: "tpo.reject", targetType: "College", targetId: "c1" })
);
expect(TpoVerificationReview.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "req1",
collegeId: "c1",
decision: "rejected",
reviewedBy: "admin1",
})
);
expect(res.json).toHaveBeenCalledWith({ success: true });
});

it("does not demote a requester who is no longer a TPO", async () => {
const college = { _id: "c1", name: "MIT", submittedBy: "req1" };
const requester = makeUser({ _id: "req1", role: "student", roles: ["student"], firebaseUid: "fb-req1" });
College.findById.mockResolvedValueOnce(college);
User.findById.mockResolvedValueOnce(requester);

await rejectTpo({ params: { collegeId: "c1" }, userDoc: makeAdmin(), actingAdminDoc: null }, res);

expect(College.deleteOne).toHaveBeenCalledWith({ _id: "c1" });
expect(requester.save).not.toHaveBeenCalled();
expect(invalidateCachedUserByFirebaseUid).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ success: true });
});
});
Expand Down
26 changes: 26 additions & 0 deletions backend/models/College.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,32 @@ const collegeSchema = new mongoose.Schema(
index: true,
},

// ── TPO email-role advisory rules ─────────────────────────────────────
// College-specific patterns are advisory evidence only. They never grant
// or deny a TPO role by themselves. This lets each institution describe
// its own mailbox conventions without hardcoding assumptions globally.
staffEmailPatterns: {
type: [
{
type: { type: String, enum: ["domain", "local_prefix", "local_regex"] },
value: { type: String, trim: true, maxlength: 253 },
values: { type: [String], default: undefined },
},
],
default: [],
},

studentEmailPatterns: {
type: [
{
type: { type: String, enum: ["domain", "local_prefix", "local_regex"] },
value: { type: String, trim: true, maxlength: 253 },
values: { type: [String], default: undefined },
},
],
default: [],
},

// ── Institutional subscription (TPO-6 Commercialization) ─────────────
// Billing belongs to the institution, not an individual TPO account.
// This keeps access intact when the primary TPO changes.
Expand Down
Loading
Loading