From 647ec4ad770b6c8e8dde2ebcfa12affd4bf15f06 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:23:27 +0530 Subject: [PATCH 01/22] feat(tpo): add tpoRoleSignalService.js --- backend/services/tpoRoleSignalService.js | 161 +++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 backend/services/tpoRoleSignalService.js diff --git a/backend/services/tpoRoleSignalService.js b/backend/services/tpoRoleSignalService.js new file mode 100644 index 00000000..bce7e1bc --- /dev/null +++ b/backend/services/tpoRoleSignalService.js @@ -0,0 +1,161 @@ +import { logger } from "../config/logger.js"; + +/** + * Advisory classification for an institutional email. + * + * IMPORTANT: these rules are per-college configuration and are never an + * authorization decision. The result only helps the reviewer understand why + * a request was routed the way it was. + * + * Supported rule shapes: + * { type: "domain", value: "staff.example.edu" } + * { type: "local_prefix", values: ["faculty", "staff"] } + * { type: "local_regex", value: "^(dr\\.|prof)" } + * + * Rules are intentionally constrained instead of accepting arbitrary regex + * strings for every purpose. The local_regex path is bounded and only applied + * to the local-part before the @. + */ +const MAX_RULES = 20; +const MAX_REGEX_LENGTH = 120; + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function splitEmail(email) { + const normalized = normalizeEmail(email); + const at = normalized.lastIndexOf("@"); + if (at <= 0 || at === normalized.length - 1) { + return { email: normalized, local: "", domain: "" }; + } + return { + email: normalized, + local: normalized.slice(0, at), + domain: normalized.slice(at + 1), + }; +} + +function matchesRule(local, domain, rule) { + if (!rule || typeof rule !== "object") return false; + const type = String(rule.type || "").toLowerCase().trim(); + + if (type === "domain") { + return domain === String(rule.value || "").toLowerCase().trim(); + } + + if (type === "local_prefix") { + const values = Array.isArray(rule.values) ? rule.values : []; + return values + .slice(0, MAX_RULES) + .some((value) => local.startsWith(String(value || "").toLowerCase().trim())); + } + + if (type === "local_regex") { + const pattern = String(rule.value || ""); + if (!pattern || pattern.length > MAX_REGEX_LENGTH) return false; + try { + return new RegExp(pattern, "i").test(local); + } catch (err) { + logger.warn({ err }, "[TpoRoleSignal] invalid local regex skipped"); + return false; + } + } + + return false; +} + +function anyRuleMatches(local, domain, rules) { + return (Array.isArray(rules) ? rules : []) + .slice(0, MAX_RULES) + .some((rule) => matchesRule(local, domain, rule)); +} + +export function classifyInstitutionalEmailRole(email, college = {}) { + const { email: normalizedEmail, local, domain } = splitEmail(email); + if (!domain) return "unknown"; + + const staffMatch = anyRuleMatches(local, domain, college.staffEmailPatterns); + const studentMatch = anyRuleMatches(local, domain, college.studentEmailPatterns); + + if (staffMatch && !studentMatch) return "staff_candidate"; + if (studentMatch && !staffMatch) return "student_candidate"; + if (staffMatch && studentMatch) return "ambiguous"; + return "unknown"; +} + +export function buildTpoVerificationSignal(email, college) { + const classification = classifyInstitutionalEmailRole(email, college); + + return { + type: "EMAIL_PATTERN", + result: classification, + capturedAt: new Date(), + }; +} + +export function isValidRolePatternRule(rule) { + if (!rule || typeof rule !== "object") return false; + const type = String(rule.type || "").toLowerCase().trim(); + + if (type === "domain") { + return ( + typeof rule.value === "string" && + /^[a-z0-9.-]+$/.test(rule.value.toLowerCase().trim()) && + rule.value.length <= 253 + ); + } + + if (type === "local_prefix") { + return ( + Array.isArray(rule.values) && + rule.values.length > 0 && + rule.values.length <= MAX_RULES && + rule.values.every( + (value) => + typeof value === "string" && + value.trim().length > 0 && + value.trim().length <= 60 + ) + ); + } + + if (type === "local_regex") { + if (typeof rule.value !== "string" || rule.value.length === 0 || rule.value.length > MAX_REGEX_LENGTH) { + return false; + } + try { + // Compile during validation so malformed expressions never become + // active configuration. + new RegExp(rule.value, "i"); + return true; + } catch { + return false; + } + } + + return false; +} + +export function sanitizeRolePatternRules(rules) { + if (!Array.isArray(rules)) return []; + return rules + .slice(0, MAX_RULES) + .filter(isValidRolePatternRule) + .map((rule) => { + const type = String(rule.type).toLowerCase().trim(); + if (type === "domain") { + return { type, value: rule.value.toLowerCase().trim() }; + } + if (type === "local_prefix") { + return { + type, + values: rule.values + .map((value) => value.toLowerCase().trim()) + .filter(Boolean) + .slice(0, MAX_RULES), + }; + } + return { type, value: rule.value }; + }); +} From fedd0163558ca4063d6aa55c939f924fc931c123 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:23:29 +0530 Subject: [PATCH 02/22] feat(tpo): add TpoVerificationReview.js --- backend/models/TpoVerificationReview.js | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/models/TpoVerificationReview.js diff --git a/backend/models/TpoVerificationReview.js b/backend/models/TpoVerificationReview.js new file mode 100644 index 00000000..7eb52dd2 --- /dev/null +++ b/backend/models/TpoVerificationReview.js @@ -0,0 +1,75 @@ +import mongoose from "mongoose"; + +/** + * One immutable review record per TPO role application decision. + * This is deliberately separate from User/College state so the audit trail + * survives later edits, role revocation, or college renames. + */ +const tpoVerificationReviewSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + collegeId: { + type: mongoose.Schema.Types.ObjectId, + ref: "College", + required: true, + index: true, + }, + requestedEmail: { + type: String, + required: true, + trim: true, + lowercase: true, + maxlength: 254, + }, + emailRoleSignal: { + type: String, + enum: ["staff_candidate", "student_candidate", "ambiguous", "unknown"], + required: true, + }, + evidence: { + type: [ + { + kind: { + type: String, + enum: ["email", "invitation", "staff_id", "document", "manual_note"], + required: true, + }, + label: { type: String, required: true, trim: true, maxlength: 120 }, + reference: { type: String, default: null, trim: true, maxlength: 500 }, + note: { type: String, default: null, trim: true, maxlength: 1000 }, + addedAt: { type: Date, default: Date.now }, + }, + ], + default: [], + }, + decision: { + type: String, + enum: ["approved", "rejected"], + required: true, + }, + decisionReason: { + type: String, + default: null, + trim: true, + maxlength: 1000, + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + reviewedAt: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +export default mongoose.model("TpoVerificationReview", tpoVerificationReviewSchema); From b90f7904b813246e76a8a35ab0d54cbaa646652a Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:23:34 +0530 Subject: [PATCH 03/22] feat(tpo): store college-specific role signal rules --- backend/models/College.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/models/College.js b/backend/models/College.js index 851fe022..d7b6957e 100644 --- a/backend/models/College.js +++ b/backend/models/College.js @@ -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. From fb622ef4043aad24578eef22e80ebec6025ff310 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:23:40 +0530 Subject: [PATCH 04/22] feat(tpo): persist advisory verification state --- backend/models/User.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/models/User.js b/backend/models/User.js index 4f054150..6a3c017c 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -348,6 +348,41 @@ const userSchema = new mongoose.Schema( verifiedAt: { type: Date, default: null }, }, + // ── TPO verification application evidence ──────────────────────────── + // Pending/approved TPO applications retain their latest advisory signals + // and evidence metadata here. This is not the authorization source of + // truth: verified TPO access still depends on tpoProfile.verified and the + // College approval flow. + tpoVerification: { + status: { + type: String, + enum: ["unset", "pending", "approved", "rejected"], + default: "unset", + }, + emailRoleSignal: { + type: String, + enum: ["staff_candidate", "student_candidate", "ambiguous", "unknown"], + default: "unknown", + }, + submittedEmail: { type: String, default: null, trim: true, lowercase: true }, + submittedAt: { type: Date, default: null }, + evidence: { + type: [ + { + kind: { + type: String, + enum: ["email", "invitation", "staff_id", "document", "manual_note"], + }, + label: { type: String, trim: true, maxlength: 120 }, + reference: { type: String, default: null, trim: true, maxlength: 500 }, + note: { type: String, default: null, trim: true, maxlength: 1000 }, + addedAt: { type: Date, default: Date.now }, + }, + ], + default: [], + }, + }, + // ── Student college verification (Phase 12C) ──────────────────────── // Distinct from tpoProfile — this is any student proving their own // college affiliation, not a TPO representing one. collegeEmail is From 1cf0c586655098f31b4f6c7c4e4df9740d8397f1 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:23:48 +0530 Subject: [PATCH 05/22] feat(tpo): capture advisory email role signal during registration --- backend/routes/tpo.js | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index c5bc3f42..ae132928 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -37,6 +37,7 @@ import { computeReadinessScore } from "../utils/readiness.js"; import multer from "multer"; import { csvUpload } from "../middleware/csvUpload.js"; import { getInstitutionSubscription } from "../services/institutionSubscriptionService.js"; +import { buildTpoVerificationSignal, classifyInstitutionalEmailRole } from "../services/tpoRoleSignalService.js"; const TPO_CACHE_TTL_SECONDS = 2 * 60; // 2 minutes — matches profile cache TTL const TPO_CACHE_PREFIX = "tpo:"; @@ -103,8 +104,16 @@ router.post("/register", async (req, res) => { const email = req.userDoc.email || ""; const domain = email.split("@")[1]; + if (!domain) { + return res.status(400).json({ + error: "Your account does not have a valid institutional email address.", + }); + } - if (!domain || isConsumerEmailDomain(domain)) { + // Advisory only: never grants or denies TPO access. The resolved college + // rules are applied after the College document is known below. + + if (isConsumerEmailDomain(domain)) { return res.status(400).json({ error: "Please sign up with your institutional email (e.g. yourname@college.ac.in), not a personal email.", }); @@ -131,6 +140,14 @@ router.post("/register", async (req, res) => { }); } + const emailRoleSignal = buildTpoVerificationSignal(email, collegeDoc || existingCollege || {}); + const emailRoleClassification = emailRoleSignal.result; + + // A matching student signal is deliberately not an automatic rejection: + // people can legitimately hold multiple institutional responsibilities + // and local mailbox conventions are advisory. It simply makes the + // evidence trail explicit for the reviewer. + const now = new Date(); // Hybrid verification (Phase B): known college domains — including one // already verified via an earlier TPO from the same college — skip the @@ -200,6 +217,22 @@ router.post("/register", async (req, res) => { verifiedAt: autoVerified ? now : null, }; + req.userDoc.tpoVerification = { + status: autoVerified ? "approved" : "pending", + emailRoleSignal: emailRoleClassification, + submittedEmail: email, + submittedAt: now, + evidence: [ + { + kind: "email", + label: "Institutional sign-in email", + reference: null, + note: "Email ownership is established by the authenticated sign-in provider; role classification remains advisory.", + addedAt: now, + }, + ], + }; + // TPO-1 hardening: partial-failure handling. The College-side write // above already committed by this point — if the User-side write // fails now, we'd otherwise strand the domain in a half-claimed state @@ -272,6 +305,11 @@ router.post("/register", async (req, res) => { verified: autoVerified, status: autoVerified ? "verified" : "pending", isPrimary, + emailRoleSignal: emailRoleClassification, + verification: { + status: autoVerified ? "approved" : "pending", + additionalEvidenceRecommended: !autoVerified || emailRoleClassification !== "staff_candidate", + }, message: autoVerified ? "Your college is verified. You're all set — head to your dashboard." : "Your college registration request has been submitted for verification.", From 950bf71a7be168dcac8dc26b1396610a675f064c Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:24:38 +0530 Subject: [PATCH 06/22] feat(tpo): add verification review trail to admin decisions --- backend/controllers/adminController.js | 166 +++++++++++++------------ 1 file changed, 89 insertions(+), 77 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index f8eff3da..0f83a276 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -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"; @@ -79,15 +80,27 @@ 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: await Promise.all( + tpoColleges.map(async (c) => { + const applicant = c.submittedBy + ? await User.findById(c.submittedBy).select("email displayName tpoVerification").lean() + : null; + const signal = applicant?.tpoVerification?.emailRoleSignal || "unknown"; + return { + collegeId: c._id, + collegeName: c.name, + 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, @@ -215,68 +228,57 @@ 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 after successful bulk-verification — continuing" - ); + logger.error({ err, collegeId: college._id }, "[Admin] approveTpo primary claim failed"); } } + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + if (reviewerId) { + 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(), + }) + ) + ); + } + recordAdminAction({ adminDoc: req.actingAdminDoc || req.userDoc, action: "tpo.approve", @@ -289,33 +291,46 @@ 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(() => {}); } - return res.json({ success: true }); + return res.json({ success: true, reviewed: pendingCandidates.length }); } 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." }); - } + 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).select("email firebaseUid role tpoVerification") + : null; + + const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; + if (reviewerId && requester) { + 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(), + }); + } - // 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({ @@ -325,29 +340,25 @@ 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.revokeRole("tpo"); + requester.role = "student"; + requester.tpoProfile = { + collegeDomain: null, + collegeName: null, + verified: false, + requestedAt: null, + verifiedAt: null, + }; + requester.tpoVerification.status = "rejected"; + 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(() => {}); } @@ -359,6 +370,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 From c846e30385e3f9f3c9b42af3ac4c160258f5cca2 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:24:45 +0530 Subject: [PATCH 07/22] test(tpo): cover tpoRoleSignalService.test.js --- backend/services/tpoRoleSignalService.test.js | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/services/tpoRoleSignalService.test.js diff --git a/backend/services/tpoRoleSignalService.test.js b/backend/services/tpoRoleSignalService.test.js new file mode 100644 index 00000000..3d8760b0 --- /dev/null +++ b/backend/services/tpoRoleSignalService.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + classifyInstitutionalEmailRole, + isValidRolePatternRule, + sanitizeRolePatternRules, +} from "./tpoRoleSignalService.js"; + +describe("tpoRoleSignalService", () => { + const college = { + staffEmailPatterns: [ + { type: "domain", value: "staff.example.edu" }, + { type: "local_prefix", values: ["staff.", "faculty."] }, + { type: "local_regex", value: "^(dr\\.|prof)" }, + ], + studentEmailPatterns: [ + { type: "domain", value: "students.example.edu" }, + { type: "local_prefix", values: ["student.", "22"] }, + ], + }; + + it("classifies a configured staff domain", () => { + expect(classifyInstitutionalEmailRole("person@staff.example.edu", college)).toBe("staff_candidate"); + }); + + it("classifies a configured student domain", () => { + expect(classifyInstitutionalEmailRole("person@students.example.edu", college)).toBe("student_candidate"); + }); + + it("classifies a staff local prefix", () => { + expect(classifyInstitutionalEmailRole("faculty.person@example.edu", college)).toBe("staff_candidate"); + }); + + it("returns ambiguous when both rule groups match", () => { + expect(classifyInstitutionalEmailRole("student.person@staff.example.edu", college)).toBe("ambiguous"); + }); + + it("returns unknown for an unmatched address", () => { + expect(classifyInstitutionalEmailRole("person@example.edu", college)).toBe("unknown"); + }); + + it("rejects unsupported and malformed rules", () => { + expect(isValidRolePatternRule({ type: "wat", value: "x" })).toBe(false); + expect(isValidRolePatternRule({ type: "domain", value: "bad domain" })).toBe(false); + expect(isValidRolePatternRule({ type: "local_regex", value: "[" })).toBe(false); + }); + + it("sanitizes and bounds rules", () => { + const rules = sanitizeRolePatternRules([ + { type: "DOMAIN", value: "STAFF.EXAMPLE.EDU" }, + { type: "LOCAL_PREFIX", values: ["Faculty.", ""] }, + { type: "wat", value: "ignored" }, + ]); + expect(rules).toEqual([ + { type: "domain", value: "staff.example.edu" }, + { type: "local_prefix", values: ["faculty."] }, + ]); + }); +}); From fb714792b5563826488bfcc0ca0c83150763b599 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:24:48 +0530 Subject: [PATCH 08/22] test(tpo): cover TpoVerificationReview.test.js --- backend/models/TpoVerificationReview.test.js | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/models/TpoVerificationReview.test.js diff --git a/backend/models/TpoVerificationReview.test.js b/backend/models/TpoVerificationReview.test.js new file mode 100644 index 00000000..37be1b83 --- /dev/null +++ b/backend/models/TpoVerificationReview.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import TpoVerificationReview from "./TpoVerificationReview.js"; + +describe("TpoVerificationReview", () => { + it("requires the reviewer decision fields", () => { + const doc = new TpoVerificationReview({}); + const error = doc.validateSync(); + expect(error?.errors?.userId).toBeTruthy(); + expect(error?.errors?.collegeId).toBeTruthy(); + expect(error?.errors?.requestedEmail).toBeTruthy(); + expect(error?.errors?.emailRoleSignal).toBeTruthy(); + expect(error?.errors?.decision).toBeTruthy(); + expect(error?.errors?.reviewedBy).toBeTruthy(); + }); + + it("accepts the four advisory email classifications", () => { + for (const signal of ["staff_candidate", "student_candidate", "ambiguous", "unknown"]) { + const doc = new TpoVerificationReview({ + userId: "507f1f77bcf86cd799439011", + collegeId: "507f1f77bcf86cd799439012", + requestedEmail: "person@example.edu", + emailRoleSignal: signal, + decision: "approved", + reviewedBy: "507f1f77bcf86cd799439013", + }); + expect(doc.validateSync()).toBeUndefined(); + } + }); +}); From b4c7006b2423c14273d5b7849c4d9f63481199f7 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sat, 26 Sep 2026 13:24:53 +0530 Subject: [PATCH 09/22] feat(tpo): surface verification evidence in admin queue --- .../admin/VerificationQueueSection.jsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/admin/VerificationQueueSection.jsx b/src/components/admin/VerificationQueueSection.jsx index 56054d2d..21a3424b 100644 --- a/src/components/admin/VerificationQueueSection.jsx +++ b/src/components/admin/VerificationQueueSection.jsx @@ -9,7 +9,7 @@ import ConfirmDialog from "../ui/ConfirmDialog"; // way for the admin to undo it from here. It gets a confirmation step; // Approve stays one-click since a fast, low-friction "yes" is exactly // what a review queue should optimize for. -function QueueRow({ title, subtitle, meta, onApprove, onReject, busy }) { +function QueueRow({ title, subtitle, meta, signal, evidenceHint, evidence, onApprove, onReject, busy }) { const [confirmingReject, setConfirmingReject] = useState(false); return ( @@ -18,6 +18,17 @@ function QueueRow({ title, subtitle, meta, onApprove, onReject, busy }) {

{title}

{subtitle}

{meta &&

{meta}

} + {signal && ( +

+ Email signal: {signal.replaceAll("_", " ")} + {evidenceHint ? " · additional evidence recommended" : ""} +

+ )} + {Array.isArray(evidence) && evidence.length > 0 && ( +

+ Evidence: {evidence.map((item) => item.label).join(", ")} +

+ )}