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, sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail, sendMentorTeamAssignmentEmail,
sendTeamMentorIntroductionEmail, sendTeamMentorIntroductionEmail,
getMentorBulkAssignmentTemplate,
getTeamMentorIntroductionTemplate,
getBaseUrl,
} from '@/lib/email' } from '@/lib/email'
import { import {
getAIMentorSuggestions, getAIMentorSuggestions,
@@ -3287,4 +3290,208 @@ export const mentorRouter = router({
return updated 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 }
}),
}) })

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendMentorBulkAssignmentEmail: vi.fn(async () => true),
sendTeamMentorIntroductionEmail: vi.fn(async () => true),
}
})
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
describe('mentor.sendMentorshipWelcome / previewMentorshipWelcome', () => {
let programId: string
const userIds: string[] = []
let roundId: string
let memberEmail: string
let mentorEmail: string
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(program.id)
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
status: 'ROUND_ACTIVE',
})
roundId = round.id
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const mentor = await createTestUser('MENTOR')
const member = await createTestUser('APPLICANT')
mentorEmail = mentor.email
memberEmail = member.email
userIds.push(mentor.id, member.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: member.id, role: 'LEAD' },
})
await prisma.mentorAssignment.create({
data: { projectId: project.id, mentorId: mentor.id, method: 'MANUAL' },
})
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('sends to mentors and team members and reports counts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const res = await caller.sendMentorshipWelcome({ roundId, customNote: 'Reminder!' })
expect(res.sent).toBeGreaterThan(0)
expect(sendMentorBulkAssignmentEmail).toHaveBeenCalled()
expect(sendTeamMentorIntroductionEmail).toHaveBeenCalled()
})
it('does NOT stamp the one-time flags (re-sendable reminder)', async () => {
const assignment = await prisma.mentorAssignment.findFirst({
where: { project: { projectRoundStates: { some: { roundId } } } },
})
expect(assignment?.notificationSentAt).toBeNull()
expect(assignment?.teamIntroducedAt).toBeNull()
})
it('preview returns non-empty mentor + team HTML with real contacts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const pv = await caller.previewMentorshipWelcome({ roundId })
expect(pv.recipientCount).toBeGreaterThan(0)
expect(pv.html).toContain('Mentor version')
expect(pv.html).toContain('Team version')
expect(pv.html).toContain(memberEmail)
expect(pv.html).toContain(mentorEmail)
})
})