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,
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user