feat(mentor): add change-request procedures + admin email notification
- mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest with a reason; one open request per (user, project) enforced - mentor.listChangeRequests: admin-only inbox listing - mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional resolution note - sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users when a request is opened (try/catch — never throws) - Mentors are NOT notified of change requests, even after resolution (per design decision in PR8 plan) - Audit log entries for create + resolve; raw reason redacted from audit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||||
|
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||||
|
<h1 style="margin:0;font-size:20px;font-weight:600;">Mentor change request</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||||
|
<p style="margin-top:0;">Hi MOPC admins,</p>
|
||||||
|
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||||||
|
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
|
||||||
|
<p style="margin-top:24px;">
|
||||||
|
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||||
|
Mentors are not notified of change requests; only admins see this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.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<void> {
|
||||||
|
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(
|
function getFinalistConfirmationTemplate(
|
||||||
name: string,
|
name: string,
|
||||||
projectTitle: string,
|
projectTitle: string,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { MentorAssignmentMethod, Prisma, type PrismaClient } from '@prisma/client'
|
import {
|
||||||
import { sendMentorTeamAssignmentEmail } from '@/lib/email'
|
MentorAssignmentMethod,
|
||||||
|
MentorChangeRequestStatus,
|
||||||
|
Prisma,
|
||||||
|
type PrismaClient,
|
||||||
|
} from '@prisma/client'
|
||||||
|
import {
|
||||||
|
sendMentorChangeRequestEmail,
|
||||||
|
sendMentorTeamAssignmentEmail,
|
||||||
|
} from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
getRoundRobinMentor,
|
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, number> = {
|
||||||
|
[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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user