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 + }), })