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:
@@ -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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
107
tests/unit/mentorship-welcome-send.test.ts
Normal file
107
tests/unit/mentorship-welcome-send.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user