diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 080896e..426e45f 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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() + const teamEmails = new Set() + 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) => + `
${label}
` + const sampleNote = isSample + ? `
No assignments in this round yet — showing sample data.
` + : '' + + 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() + 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() + 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 } + }), }) diff --git a/tests/unit/mentorship-welcome-send.test.ts b/tests/unit/mentorship-welcome-send.test.ts new file mode 100644 index 0000000..7870200 --- /dev/null +++ b/tests/unit/mentorship-welcome-send.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' + +vi.mock('@/lib/email', async () => { + const actual = await vi.importActual('@/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) + }) +})