diff --git a/src/lib/email.ts b/src/lib/email.ts
index 5463a94..9c6bdba 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -2826,6 +2826,103 @@ export async function sendMentorTeamAssignmentEmail(
}
}
+// =============================================================================
+// Mentor change requests (PR 8) — admin notification when an applicant or admin
+// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
+// =============================================================================
+
+function getMentorChangeRequestTemplate(
+ projectTitle: string,
+ requesterName: string | null,
+ reason: string,
+ adminDashboardUrl: string,
+): EmailTemplate {
+ const subject = `Mentor change request for "${projectTitle}"`
+ const requesterLabel = requesterName || 'a team member'
+ const text = [
+ 'Hi MOPC admins,',
+ '',
+ `A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
+ '',
+ 'Reason:',
+ `"${reason}"`,
+ '',
+ `Review the request: ${adminDashboardUrl}`,
+ '',
+ 'The MOPC team',
+ ].join('\n')
+
+ const html = `
+
+
+
+
+
+
Mentor change request
+
+
+
Hi MOPC admins,
+
A mentor change request has been opened by ${escapeHtml(requesterLabel)} for the project ${escapeHtml(projectTitle)}.
+
${escapeHtml(reason)}
+
+ Review Request
+
+
+ Mentors are not notified of change requests; only admins see this.
+
+
+
+ Monaco Ocean Protection Challenge
+
+
+
+
+ `.trim()
+
+ return { subject, text, html }
+}
+
+/**
+ * Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
+ * has been opened for a project. Sends one email per recipient.
+ * Never throws — failures are caught and logged so the calling mutation
+ * (mentor.requestChange) never fails because of email infrastructure issues.
+ */
+export async function sendMentorChangeRequestEmail(
+ adminEmails: string[],
+ projectTitle: string,
+ requesterName: string | null,
+ reason: string,
+ adminDashboardUrl: string,
+): Promise {
+ try {
+ if (adminEmails.length === 0) {
+ console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
+ return
+ }
+ const template = getMentorChangeRequestTemplate(
+ projectTitle,
+ requesterName,
+ reason,
+ adminDashboardUrl,
+ )
+ await Promise.all(
+ adminEmails.map((email) =>
+ sendEmail({
+ to: email,
+ subject: template.subject,
+ text: template.text,
+ html: template.html,
+ }).catch((err) => {
+ console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
+ }),
+ ),
+ )
+ } catch (error) {
+ console.error('[sendMentorChangeRequestEmail] failed', { error })
+ }
+}
+
function getFinalistConfirmationTemplate(
name: string,
projectTitle: string,
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts
index f39dc29..1677703 100644
--- a/src/server/routers/mentor.ts
+++ b/src/server/routers/mentor.ts
@@ -1,8 +1,16 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
-import { MentorAssignmentMethod, Prisma, type PrismaClient } from '@prisma/client'
-import { sendMentorTeamAssignmentEmail } from '@/lib/email'
+import {
+ MentorAssignmentMethod,
+ MentorChangeRequestStatus,
+ Prisma,
+ type PrismaClient,
+} from '@prisma/client'
+import {
+ sendMentorChangeRequestEmail,
+ sendMentorTeamAssignmentEmail,
+} from '@/lib/email'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
@@ -2503,4 +2511,243 @@ export const mentorRouter = router({
})),
}
}),
+
+ // ===========================================================================
+ // Mentor change requests (PR8)
+ //
+ // Applicants (team members) or admins can open a PENDING change request for
+ // a project — optionally targeting a specific co-mentor assignment. Admins
+ // are notified by email; mentors are intentionally NOT notified, even after
+ // resolution (per design decision in the PR8 plan).
+ // ===========================================================================
+
+ /**
+ * Open a new mentor change request. Allowed for:
+ * • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
+ * • a team member of the target project.
+ *
+ * Rejects with CONFLICT if the same user already has an open (PENDING) request
+ * for the same project. The raw `reason` is intentionally NOT included in
+ * audit logs — only its length — for privacy. Email delivery to admins is
+ * best-effort and never throws.
+ */
+ requestChange: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().min(1),
+ targetAssignmentId: z.string().min(1).optional(),
+ reason: z.string().min(10).max(2000),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+
+ // Authorization: admin OR team member of the project
+ if (!isAdmin) {
+ const teamMembership = await ctx.prisma.teamMember.findFirst({
+ where: { projectId: input.projectId, userId: ctx.user.id },
+ select: { id: true },
+ })
+ if (!teamMembership) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'You are not a member of this project',
+ })
+ }
+ }
+
+ // Load project (also confirms it exists) and validate optional target
+ const project = await ctx.prisma.project.findUnique({
+ where: { id: input.projectId },
+ select: { id: true, title: true },
+ })
+ if (!project) {
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
+ }
+
+ if (input.targetAssignmentId) {
+ const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
+ where: { id: input.targetAssignmentId },
+ select: { id: true, projectId: true },
+ })
+ if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Target assignment does not belong to this project',
+ })
+ }
+ }
+
+ // One open request per (user, project)
+ const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
+ where: {
+ projectId: input.projectId,
+ requestedByUserId: ctx.user.id,
+ status: MentorChangeRequestStatus.PENDING,
+ },
+ select: { id: true },
+ })
+ if (existingOpen) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'You already have an open mentor change request for this project.',
+ })
+ }
+
+ const created = await ctx.prisma.mentorChangeRequest.create({
+ data: {
+ projectId: input.projectId,
+ targetAssignmentId: input.targetAssignmentId ?? null,
+ requestedByUserId: ctx.user.id,
+ reason: input.reason,
+ status: MentorChangeRequestStatus.PENDING,
+ },
+ select: { id: true, status: true, createdAt: true },
+ })
+
+ // Notify admins (best-effort, never throw)
+ try {
+ const admins = await ctx.prisma.user.findMany({
+ where: {
+ OR: [
+ { roles: { has: 'SUPER_ADMIN' } },
+ { roles: { has: 'PROGRAM_ADMIN' } },
+ ],
+ status: 'ACTIVE',
+ },
+ select: { email: true },
+ })
+ const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
+ const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
+ await sendMentorChangeRequestEmail(
+ admins.map((a) => a.email),
+ project.title,
+ ctx.user.name ?? null,
+ input.reason,
+ adminDashboardUrl,
+ )
+ } catch (err) {
+ // Defense-in-depth: the helper already has its own try/catch
+ console.error('[mentor.requestChange] notify admins failed:', err)
+ }
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'MENTOR_CHANGE_REQUEST_CREATE',
+ entityType: 'MentorChangeRequest',
+ entityId: created.id,
+ detailsJson: {
+ projectId: input.projectId,
+ targetAssignmentId: input.targetAssignmentId ?? null,
+ reasonLength: input.reason.length,
+ },
+ })
+
+ return created
+ }),
+
+ /**
+ * Admin inbox — list MentorChangeRequest rows, optionally filtered by status
+ * and/or project. PENDING rows are surfaced first; within each status group
+ * rows are ordered by createdAt desc. No pagination (low-volume admin view).
+ */
+ listChangeRequests: adminProcedure
+ .input(
+ z
+ .object({
+ status: z.nativeEnum(MentorChangeRequestStatus).optional(),
+ projectId: z.string().optional(),
+ })
+ .optional(),
+ )
+ .query(async ({ ctx, input }) => {
+ const where: Prisma.MentorChangeRequestWhereInput = {}
+ if (input?.status) where.status = input.status
+ if (input?.projectId) where.projectId = input.projectId
+
+ const rows = await ctx.prisma.mentorChangeRequest.findMany({
+ where,
+ include: {
+ project: { select: { id: true, title: true } },
+ targetAssignment: {
+ select: {
+ id: true,
+ mentor: { select: { id: true, name: true, email: true } },
+ },
+ },
+ requestedBy: { select: { id: true, name: true, email: true } },
+ resolvedBy: { select: { id: true, name: true, email: true } },
+ },
+ // PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
+ orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
+ })
+
+ // Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
+ // is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
+ // appears first regardless of enum string ordering.
+ const statusRank: Record = {
+ [MentorChangeRequestStatus.PENDING]: 0,
+ [MentorChangeRequestStatus.RESOLVED]: 1,
+ [MentorChangeRequestStatus.DISMISSED]: 2,
+ }
+ return rows.sort((a, b) => {
+ const sa = statusRank[a.status] - statusRank[b.status]
+ if (sa !== 0) return sa
+ return b.createdAt.getTime() - a.createdAt.getTime()
+ })
+ }),
+
+ /**
+ * Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
+ * is rejected. No email or notification is sent to the requester or mentors
+ * (per PR8 design decision — mentors are never informed of change requests).
+ */
+ resolveChangeRequest: adminProcedure
+ .input(
+ z.object({
+ id: z.string().min(1),
+ status: z.enum(['RESOLVED', 'DISMISSED']),
+ resolutionNote: z.string().max(2000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const existing = await ctx.prisma.mentorChangeRequest.findUnique({
+ where: { id: input.id },
+ select: { id: true, status: true, projectId: true },
+ })
+ if (!existing) {
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
+ }
+ if (existing.status !== MentorChangeRequestStatus.PENDING) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Request already resolved',
+ })
+ }
+
+ const updated = await ctx.prisma.mentorChangeRequest.update({
+ where: { id: existing.id },
+ data: {
+ status: input.status as MentorChangeRequestStatus,
+ resolvedByUserId: ctx.user.id,
+ resolvedAt: new Date(),
+ resolutionNote: input.resolutionNote ?? null,
+ },
+ })
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
+ entityType: 'MentorChangeRequest',
+ entityId: existing.id,
+ detailsJson: {
+ status: input.status,
+ projectId: existing.projectId,
+ },
+ })
+
+ return updated
+ }),
})