feat(mentor): admin preview + send mentorship welcome/reminder email

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-01 16:40:38 +02:00
parent 32116dac75
commit 829b082912
2 changed files with 314 additions and 0 deletions

View File

@@ -12,6 +12,9 @@ import {
sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail,
sendTeamMentorIntroductionEmail,
getMentorBulkAssignmentTemplate,
getTeamMentorIntroductionTemplate,
getBaseUrl,
} from '@/lib/email'
import {
getAIMentorSuggestions,
@@ -3287,4 +3290,208 @@ export const mentorRouter = router({
return updated
}),
previewMentorshipWelcome: adminProcedure
.input(z.object({ roundId: z.string(), customNote: z.string().max(2000).optional() }))
.query(async ({ ctx, input }) => {
const { roundId, customNote } = input
const baseUrl = getBaseUrl()
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { droppedAt: null, project: { projectRoundStates: { some: { roundId } } } },
select: {
mentorId: true,
mentor: { select: { name: true, email: true } },
project: {
select: {
id: true,
title: true,
teamMembers: { select: { user: { select: { name: true, email: true } } } },
},
},
},
})
const mentorIds = new Set<string>()
const teamEmails = new Set<string>()
for (const a of assignments) {
if (a.mentor?.email) mentorIds.add(a.mentorId)
for (const tm of a.project.teamMembers) {
if (tm.user?.email) teamEmails.add(tm.user.email)
}
}
const recipientCount = mentorIds.size + teamEmails.size
const firstMentor = assignments.find((a) => a.mentor?.email)
const mentorTemplate = firstMentor
? getMentorBulkAssignmentTemplate(
firstMentor.mentor!.name || '',
assignments
.filter((a) => a.mentorId === firstMentor.mentorId)
.map((a) => ({
title: a.project.title,
url: `${baseUrl}/mentor/workspace/${a.project.id}`,
teamMembers: a.project.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user!.name, email: tm.user!.email })),
})),
`${baseUrl}/mentor`,
customNote,
)
: getMentorBulkAssignmentTemplate(
'Sample Mentor',
[
{
title: 'Sample Project',
url: `${baseUrl}/mentor`,
teamMembers: [{ name: 'Sample Applicant', email: 'applicant@example.com' }],
},
],
`${baseUrl}/mentor`,
customNote,
)
const firstProject = assignments.find((a) => a.mentor?.email)
let teamTemplate
if (firstProject) {
const projMentors = assignments
.filter((a) => a.project.id === firstProject.project.id && a.mentor?.email)
.map((a) => ({ name: a.mentor!.name, email: a.mentor!.email }))
const teammates = firstProject.project.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user!.name, email: tm.user!.email }))
teamTemplate = getTeamMentorIntroductionTemplate(
teammates[0]?.name ?? null,
firstProject.project.title,
projMentors,
`${baseUrl}/applicant/mentor`,
teammates.slice(1),
customNote,
)
} else {
teamTemplate = getTeamMentorIntroductionTemplate(
'Sample Applicant',
'Sample Project',
[{ name: 'Sample Mentor', email: 'mentor@example.com' }],
`${baseUrl}/applicant/mentor`,
[{ name: 'Sample Teammate', email: 'teammate@example.com' }],
customNote,
)
}
const isSample = !firstMentor
const banner = (label: string) =>
`<div style="max-width:560px;margin:24px auto 0;padding:10px 14px;background:#053d57;color:#fff;font-weight:600;text-align:center;border-radius:6px;font-family:-apple-system,sans-serif;">${label}</div>`
const sampleNote = isSample
? `<div style="max-width:560px;margin:16px auto 0;padding:10px 14px;background:#fff7ed;color:#9a3412;text-align:center;border-radius:6px;font-family:-apple-system,sans-serif;">No assignments in this round yet — showing sample data.</div>`
: ''
const html = `${sampleNote}${banner('Mentor version')}${mentorTemplate.html}${banner('Team version')}${teamTemplate.html}`
return { html, recipientCount }
}),
sendMentorshipWelcome: adminProcedure
.input(z.object({ roundId: z.string(), customNote: z.string().max(2000).optional() }))
.mutation(async ({ ctx, input }) => {
const { roundId, customNote } = input
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { droppedAt: null, project: { projectRoundStates: { some: { roundId } } } },
select: {
id: true,
mentorId: true,
mentor: { select: { name: true, email: true } },
project: {
select: {
id: true,
title: true,
teamMembers: { select: { user: { select: { name: true, email: true } } } },
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
},
},
})
let sent = 0
let failed = 0
// Mentor emails (coalesced per mentor).
const perMentor = new Map<
string,
{
email: string
name: string | null
projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
}
>()
for (const a of assignments) {
if (!a.mentor?.email) continue
const bucket = perMentor.get(a.mentorId) ?? {
email: a.mentor.email,
name: a.mentor.name,
projects: [],
}
bucket.projects.push({
id: a.project.id,
title: a.project.title,
teamMembers: a.project.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user!.name, email: tm.user!.email })),
})
perMentor.set(a.mentorId, bucket)
}
for (const bucket of perMentor.values()) {
const ok = await sendMentorBulkAssignmentEmail(bucket.email, bucket.name, bucket.projects, customNote)
if (ok) sent++
else failed++
}
// Team emails (per project, to all members + original submitter).
const byProject = new Map<string, typeof assignments>()
for (const a of assignments) {
const arr = byProject.get(a.project.id) ?? []
arr.push(a)
byProject.set(a.project.id, arr)
}
for (const projAssignments of byProject.values()) {
const p = projAssignments[0].project
const mentors = projAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({ name: a.mentor!.name, email: a.mentor!.email }))
if (mentors.length === 0) continue
const allMembers = p.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user!.name, email: tm.user!.email }))
const recipients = new Map<string, { name: string | null }>()
for (const m of allMembers) recipients.set(m.email, { name: m.name })
if (p.submittedByEmail && !recipients.has(p.submittedByEmail)) {
recipients.set(p.submittedByEmail, { name: p.submittedBy?.name ?? null })
}
for (const [email, { name }] of recipients) {
const teammates = allMembers.filter((m) => m.email !== email)
const ok = await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates, customNote)
if (ok) sent++
else failed++
}
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTORSHIP_WELCOME_SENT',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, hasCustomNote: !!customNote },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch (err) {
console.error('[sendMentorshipWelcome] audit failed', err)
}
return { sent, failed }
}),
})