Files
MOPC-Portal/docs/superpowers/plans/2026-06-01-mentorship-comms-and-welcome-email.md
2026-06-01 16:20:05 +02:00

42 KiB
Raw Blame History

Mentorship Communications & Welcome/Reminder Email — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give mentors a one-click "email all team members" button, and turn the mentoring round-open assignment emails into instructional welcome emails (with contact addresses) that an admin can also re-send on demand, with a live preview.

Architecture: Two existing email templates in src/lib/email.ts are enhanced in place (always-on instructions block; optional team-contact lists and custom note) so they serve as a single source of truth used by both the round-open auto-send and a new admin reminder action. Two new admin tRPC procedures (previewMentorshipWelcome query, sendMentorshipWelcome mutation) live in src/server/routers/mentor.ts. A new admin button reuses the existing EmailPreviewDialog. A small client-only mailto button is added to the mentor project page.

Tech Stack: Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, React, shadcn/ui, Vitest 4, sonner toasts, Nodemailer.

Spec: docs/superpowers/specs/2026-06-01-mentorship-comms-and-welcome-email-design.md

Refinements vs spec (discovered during planning): preview reuses EmailPreviewDialog with a stacked Mentor/Team preview rather than a custom tabbed iframe; the manual blast logs via logAudit (matching how these emails already work) rather than NotificationLog; new template inputs are optional for backward compatibility.


File Structure

File Responsibility Action
src/lib/email.ts Email templates + senders Modify: export + enhance two templates and two senders; export getBaseUrl
src/server/services/round-engine.ts Round state machine Modify: pass team-member/teammate data into the upgraded emails on round-open
src/server/routers/mentor.ts Mentor tRPC procedures Modify: add previewMentorshipWelcome + sendMentorshipWelcome
src/components/admin/round/send-mentorship-welcome-button.tsx Admin reminder button Create
src/app/(admin)/admin/rounds/[roundId]/page.tsx Round detail page Modify: wire button into Notifications section (mentoring only)
src/app/(mentor)/mentor/projects/[id]/page.tsx Mentor project detail Modify: add "Email all team members" mailto button
tests/unit/mentor-welcome-email.test.ts Template unit tests Create
tests/unit/mentorship-welcome-send.test.ts tRPC procedure tests Create

Task 1: Enhance & export the two mentorship email templates (TDD)

Files:

  • Modify: src/lib/email.ts (templates at lines 28352913 and 29493015; helper getBaseUrl at lines 225228)

  • Test: tests/unit/mentor-welcome-email.test.ts (create)

  • Step 1: Write the failing template tests

Create tests/unit/mentor-welcome-email.test.ts:

import { describe, it, expect } from 'vitest'
import {
  getMentorBulkAssignmentTemplate,
  getTeamMentorIntroductionTemplate,
} from '@/lib/email'

describe('getMentorBulkAssignmentTemplate', () => {
  it('includes team-member emails, the instructions block, and a custom note', () => {
    const t = getMentorBulkAssignmentTemplate(
      'Alice',
      [
        {
          title: 'Reef Project',
          url: 'https://x/mentor/workspace/1',
          teamMembers: [{ name: 'Bob', email: 'bob@team.com' }],
        },
      ],
      'https://x/mentor',
      'Please reach out this week.',
    )
    expect(t.html).toContain('bob@team.com')
    expect(t.html).toContain('How to mentor on MOPC')
    expect(t.html).toContain('Please reach out this week.')
    expect(t.text).toContain('bob@team.com')
    expect(t.text).toContain('How to mentor on MOPC')
  })

  it('renders without team members or note (backward compatible)', () => {
    const t = getMentorBulkAssignmentTemplate(
      'Alice',
      [{ title: 'P', url: 'https://x/p' }],
      'https://x/mentor',
    )
    expect(t.html).toContain('How to mentor on MOPC')
    // Custom-note box uses the #fff7ed background; absent when no note passed.
    expect(t.html).not.toContain('#fff7ed')
  })
})

describe('getTeamMentorIntroductionTemplate', () => {
  it('includes mentor + teammate emails, the instructions block, and a custom note', () => {
    const t = getTeamMentorIntroductionTemplate(
      'Bob',
      'Reef Project',
      [{ name: 'Alice', email: 'alice@mentor.com' }],
      'https://x/applicant/mentor',
      [{ name: 'Carol', email: 'carol@team.com' }],
      'Welcome aboard!',
    )
    expect(t.html).toContain('alice@mentor.com')
    expect(t.html).toContain('carol@team.com')
    expect(t.html).toContain('Working with your mentor')
    expect(t.html).toContain('Welcome aboard!')
  })

  it('renders without teammates or note (backward compatible)', () => {
    const t = getTeamMentorIntroductionTemplate(
      'Bob',
      'Reef Project',
      [{ name: 'Alice', email: 'alice@mentor.com' }],
      'https://x/applicant/mentor',
    )
    expect(t.html).toContain('Working with your mentor')
    expect(t.html).not.toContain('#fff7ed')
  })
})
  • Step 2: Run the tests to verify they fail

Run: npx vitest run tests/unit/mentor-welcome-email.test.ts Expected: FAIL — getMentorBulkAssignmentTemplate/getTeamMentorIntroductionTemplate are not exported (import error), or the new params/sections are missing.

  • Step 3: Export getBaseUrl

In src/lib/email.ts, change the declaration at line 225 from:

function getBaseUrl(): string {

to:

export function getBaseUrl(): string {
  • Step 4: Replace getMentorBulkAssignmentTemplate (lines 29493015) with the enhanced, exported version
export function getMentorBulkAssignmentTemplate(
  name: string,
  projects: {
    title: string
    url: string
    teamMembers?: { name: string | null; email: string }[]
  }[],
  mentorDashboardUrl: string,
  customNote?: string,
): EmailTemplate {
  const count = projects.length
  const subject =
    count === 1
      ? `You've been assigned to a new MOPC project: "${projects[0].title}"`
      : `You've been assigned to ${count} new MOPC projects`
  const greeting = name ? `Hi ${name},` : 'Hi there,'

  const textBlocks = projects.map((p) => {
    const members =
      p.teamMembers && p.teamMembers.length > 0
        ? '\n' +
          p.teamMembers
            .map((m) => `      - ${m.name ?? 'Team member'}: ${m.email}`)
            .join('\n')
        : ''
    return `  • ${p.title}${p.url}${members}`
  })
  const text = [
    greeting,
    '',
    ...(customNote ? [customNote, ''] : []),
    count === 1
      ? `You have been assigned as a mentor to a new project:`
      : `You have been assigned as a mentor to ${count} new projects:`,
    '',
    ...textBlocks,
    '',
    'How to mentor on MOPC:',
    '  - Open each project workspace from your Mentor Dashboard to chat with the team, share files, and track milestones.',
    '  - Messages you send in the workspace notify the team by email automatically.',
    '  - You can also email team members directly using the addresses listed above.',
    '',
    `Open your mentor dashboard: ${mentorDashboardUrl}`,
    '',
    'The MOPC team',
  ].join('\n')

  const customNoteHtml = customNote
    ? `<div style="margin:0 0 16px;padding:12px 16px;background:#fff7ed;border-left:3px solid #de0f1e;border-radius:4px;color:#0f172a;">${escapeHtml(customNote)}</div>`
    : ''

  const htmlList = projects
    .map((p) => {
      const members =
        p.teamMembers && p.teamMembers.length > 0
          ? `<table style="width:100%;border-collapse:collapse;margin:6px 0 0;font-size:13px;">${p.teamMembers
              .map(
                (m) =>
                  `<tr><td style="padding:3px 0;color:#0f172a;">${escapeHtml(m.name ?? 'Team member')}</td><td style="padding:3px 0;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td></tr>`,
              )
              .join('')}</table>`
          : ''
      return `<li style="margin:10px 0;"><a href="${p.url}" style="color:#053d57;text-decoration:none;font-weight:600;">${escapeHtml(p.title)}</a>${members}</li>`
    })
    .join('')

  const instructionsHtml = `
    <div style="margin:16px 0;padding:12px 16px;background:#eff6ff;border-left:3px solid #3b82f6;border-radius:4px;">
      <strong style="color:#1e40af;">How to mentor on MOPC</strong>
      <ul style="margin:8px 0 0 20px;padding:0;color:#0f172a;font-size:13px;">
        <li style="margin:4px 0;">Open each project workspace from your Mentor Dashboard to chat with the team, share files, and track milestones.</li>
        <li style="margin:4px 0;">Messages you send in the workspace notify the team by email automatically.</li>
        <li style="margin:4px 0;">You can also email team members directly using the addresses listed above.</li>
      </ul>
    </div>`

  const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
  <div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
    <div style="background:#053d57;padding:24px 28px;color:#fefefe;">
      <h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'New mentor assignment' : `${count} new mentor assignments`}</h1>
    </div>
    <div style="padding:24px 28px;line-height:1.5;font-size:14px;">
      <p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
      ${customNoteHtml}
      <p>${count === 1 ? 'You have been assigned as a mentor to a new project:' : `You have been assigned as a mentor to <strong>${count}</strong> new projects:`}</p>
      <ul style="padding-left:20px;margin:12px 0 20px;">${htmlList}</ul>
      ${instructionsHtml}
      <p style="margin-top:24px;">
        <a href="${mentorDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Dashboard</a>
      </p>
    </div>
    <div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
      Monaco Ocean Protection Challenge
    </div>
  </div>
</body>
</html>
  `.trim()

  return { subject, text, html }
}
  • Step 5: Replace getTeamMentorIntroductionTemplate (lines 28352913) with the enhanced, exported version
export function getTeamMentorIntroductionTemplate(
  recipientName: string | null,
  projectTitle: string,
  mentors: { name: string | null; email: string }[],
  workspaceUrl: string,
  teammates?: { name: string | null; email: string }[],
  customNote?: string,
): EmailTemplate {
  const count = mentors.length
  const subject =
    count === 1
      ? `Your mentor for "${projectTitle}" on MOPC`
      : `Your ${count} mentors for "${projectTitle}" on MOPC`
  const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'

  const mentorTextLines = mentors
    .map((m) => `  • ${m.name ?? 'Mentor'}${m.email}`)
    .join('\n')
  const teammateTextLines =
    teammates && teammates.length > 0
      ? ['', 'Your team:', ...teammates.map((t) => `  • ${t.name ?? 'Team member'}${t.email}`)]
      : []

  const text = [
    greeting,
    '',
    ...(customNote ? [customNote, ''] : []),
    count === 1
      ? `The mentoring round is now open, and your project "${projectTitle}" has a mentor:`
      : `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`,
    '',
    mentorTextLines,
    ...teammateTextLines,
    '',
    'Working with your mentor:',
    '  - Go to the Mentoring section of your applicant portal to message your mentor directly — they are notified by email when you write.',
    '  - Share documents and questions early; your mentor is here to help you sharpen your project before the finals.',
    '  - You can also email your mentor directly using the address above.',
    '',
    `Open your mentoring page: ${workspaceUrl}`,
    '',
    'The MOPC team',
  ].join('\n')

  const customNoteHtml = customNote
    ? `<div style="margin:0 0 16px;padding:12px 16px;background:#fff7ed;border-left:3px solid #de0f1e;border-radius:4px;color:#0f172a;">${escapeHtml(customNote)}</div>`
    : ''

  const mentorHtmlList = mentors
    .map(
      (m) => `
        <tr>
          <td style="padding:6px 0;font-weight:600;color:#0f172a;">${escapeHtml(m.name ?? 'Mentor')}</td>
          <td style="padding:6px 0;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td>
        </tr>`,
    )
    .join('')

  const teammatesHtml =
    teammates && teammates.length > 0
      ? `
      <h2 style="margin:24px 0 8px;color:#0f172a;font-size:15px;font-weight:600;">Your team</h2>
      <table style="width:100%;border-collapse:collapse;margin:0 0 8px;font-size:14px;">${teammates
        .map(
          (t) => `
        <tr>
          <td style="padding:6px 0;color:#0f172a;">${escapeHtml(t.name ?? 'Team member')}</td>
          <td style="padding:6px 0;"><a href="mailto:${escapeHtml(t.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(t.email)}</a></td>
        </tr>`,
        )
        .join('')}</table>`
      : ''

  const instructionsHtml = `
    <div style="margin:16px 0;padding:12px 16px;background:#eff6ff;border-left:3px solid #3b82f6;border-radius:4px;">
      <strong style="color:#1e40af;">Working with your mentor</strong>
      <ul style="margin:8px 0 0 20px;padding:0;color:#0f172a;font-size:13px;">
        <li style="margin:4px 0;">Go to the Mentoring section of your applicant portal to message your mentor directly — they are notified by email when you write.</li>
        <li style="margin:4px 0;">Share documents and questions early; your mentor is here to help you sharpen your project before the finals.</li>
        <li style="margin:4px 0;">You can also email your mentor directly using the address above.</li>
      </ul>
    </div>`

  const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
  <div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
    <div style="background:#053d57;padding:24px 28px;color:#fefefe;">
      <h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'Meet your mentor' : `Meet your ${count} mentors`}</h1>
    </div>
    <div style="padding:24px 28px;line-height:1.5;font-size:14px;">
      <p style="margin-top:0;">${recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,'}</p>
      ${customNoteHtml}
      <p>${count === 1
        ? `The mentoring round is now open and a mentor has been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`
        : `The mentoring round is now open and <strong>${count}</strong> mentors have been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`}</p>
      <table style="width:100%;border-collapse:collapse;margin:12px 0 8px;font-size:14px;">${mentorHtmlList}</table>
      ${teammatesHtml}
      ${instructionsHtml}
      <p style="margin-top:24px;">
        <a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Workspace</a>
      </p>
    </div>
    <div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
      Monaco Ocean Protection Challenge
    </div>
  </div>
</body>
</html>
  `.trim()

  return { subject, text, html }
}
  • Step 6: Run the tests to verify they pass

Run: npx vitest run tests/unit/mentor-welcome-email.test.ts Expected: PASS (4 tests).

  • Step 7: Commit
git add src/lib/email.ts tests/unit/mentor-welcome-email.test.ts
git commit -m "feat(email): instructions + contact emails + optional note in mentorship templates"

Task 2: Update the two senders to forward new data and return success

Files:

  • Modify: src/lib/email.ts (sendTeamMentorIntroductionEmail lines 29212947; sendMentorBulkAssignmentEmail lines 30233044)

  • Step 1: Replace sendMentorBulkAssignmentEmail (lines 30233044)

export async function sendMentorBulkAssignmentEmail(
  email: string,
  name: string | null,
  projects: { id: string; title: string; teamMembers?: { name: string | null; email: string }[] }[],
  customNote?: string,
): Promise<boolean> {
  try {
    if (projects.length === 0) return false
    const baseUrl = getBaseUrl()
    const enriched = projects.map((p) => ({
      title: p.title,
      url: `${baseUrl}/mentor/workspace/${p.id}`,
      teamMembers: p.teamMembers,
    }))
    const template = getMentorBulkAssignmentTemplate(name || '', enriched, `${baseUrl}/mentor`, customNote)
    await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
    return true
  } catch (error) {
    console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
    return false
  }
}
  • Step 2: Replace sendTeamMentorIntroductionEmail (lines 29212947)
export async function sendTeamMentorIntroductionEmail(
  recipientEmail: string,
  recipientName: string | null,
  projectTitle: string,
  projectId: string,
  mentors: { name: string | null; email: string }[],
  teammates?: { name: string | null; email: string }[],
  customNote?: string,
): Promise<boolean> {
  try {
    if (mentors.length === 0) return false
    const baseUrl = getBaseUrl()
    const workspaceUrl = `${baseUrl}/applicant/mentor`
    const template = getTeamMentorIntroductionTemplate(
      recipientName,
      projectTitle,
      mentors,
      workspaceUrl,
      teammates,
      customNote,
    )
    await sendEmail({
      to: recipientEmail,
      subject: template.subject,
      text: template.text,
      html: template.html,
    })
    return true
  } catch (error) {
    console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
    return false
  }
}
  • Step 3: Verify nothing broke — typecheck

Run: npm run typecheck Expected: PASS. Existing callers (mentor.ts:132, mentor.ts:893, mentor.ts:1375, round-engine.ts:261, round-engine.ts:340) still compile because all new params are optional and the boolean return is ignored by them.

  • Step 4: Commit
git add src/lib/email.ts
git commit -m "feat(email): mentorship senders forward contacts/note, return success"

Task 3: Pass team-member & teammate data into round-open emails

Files:

  • Modify: src/server/services/round-engine.ts (mentor query lines 225237 + grouping 238270; team query 288312 + loop 313341)

  • Test: tests/unit/mentor-email-deferral.test.ts (existing — must still pass)

  • Step 1: Add teamMembers to the mentor-assignment query (lines 225237)

Replace the select block's project line so the query reads:

  const pendingAssignments = await prisma.mentorAssignment.findMany({
    where: {
      droppedAt: null,
      notificationSentAt: 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 } } } },
        },
      },
    },
  })
  • Step 2: Carry team members into the per-mentor bucket (lines 238270)

Update the Map generic and the push so the grouping block reads:

  const perMentor = new Map<
    string,
    {
      email: string | null
      name: string | null
      assignmentIds: string[]
      projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
    }
  >()
  for (const a of pendingAssignments) {
    if (!a.mentor?.email) continue
    const bucket = perMentor.get(a.mentorId) ?? {
      email: a.mentor.email,
      name: a.mentor.name,
      assignmentIds: [],
      projects: [],
    }
    bucket.assignmentIds.push(a.id)
    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)
  }

The existing sendMentorBulkAssignmentEmail(bucket.email, bucket.name, bucket.projects) call (line ~261) needs no change — bucket.projects now carries teamMembers.

  • Step 3: Pass teammates into the team-introduction call (lines 313341)

Inside the for (const p of projectsToIntroduce) loop, after mentors is built and before the recipient loop, add an allMembers list, and pass per-recipient teammates into the send call:

    const allMembers = p.teamMembers
      .filter((tm) => tm.user?.email)
      .map((tm) => ({ name: tm.user.name, email: tm.user.email }))

    for (const [email, { name }] of recipients) {
      const teammates = allMembers.filter((m) => m.email !== email)
      await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates)
    }

(Replace the existing for (const [email, { name }] of recipients) { await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors) } block.)

  • Step 4: Run the existing round-open email test + typecheck

Run: npx vitest run tests/unit/mentor-email-deferral.test.ts Expected: PASS (behavior unchanged — emails still deferred on draft, sent on active).

Run: npm run typecheck Expected: PASS.

  • Step 5: Commit
git add src/server/services/round-engine.ts
git commit -m "feat(mentor): round-open emails now carry team-member contacts"

Task 4: Add previewMentorshipWelcome + sendMentorshipWelcome procedures (TDD)

Files:

  • Modify: src/server/routers/mentor.ts (imports near lines 19; append procedures before the router close at line 3290)

  • Test: tests/unit/mentorship-welcome-send.test.ts (create)

  • Step 1: Write the failing tRPC test

Create tests/unit/mentorship-welcome-send.test.ts:

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)
  })
})
  • Step 2: Run the test to verify it fails

Run: npx vitest run tests/unit/mentorship-welcome-send.test.ts Expected: FAIL — caller.sendMentorshipWelcome is not a function / caller.previewMentorshipWelcome is not a function.

  • Step 3: Add the email imports to mentor.ts

In the existing import from @/lib/email near the top of src/server/routers/mentor.ts, add the three new names (keep sendMentorBulkAssignmentEmail / sendTeamMentorIntroductionEmail, which are already imported):

import {
  sendMentorBulkAssignmentEmail,
  sendTeamMentorIntroductionEmail,
  getMentorBulkAssignmentTemplate,
  getTeamMentorIntroductionTemplate,
  getBaseUrl,
} from '@/lib/email'

(If the existing import lists other names, preserve them and just append these. logAudit is already imported in this file — it is used by the assign procedure.)

  • Step 4: Append the two procedures before the router's closing }) (line 3290)
  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 }
    }),
  • Step 5: Run the test to verify it passes

Run: npx vitest run tests/unit/mentorship-welcome-send.test.ts Expected: PASS (3 tests).

  • Step 6: Typecheck

Run: npm run typecheck Expected: PASS. (If logAudit is not already imported in mentor.ts, add import { logAudit } from '@/server/utils/audit' — but it is used by the existing assign procedure, so it should already be present.)

  • Step 7: Commit
git add src/server/routers/mentor.ts tests/unit/mentorship-welcome-send.test.ts
git commit -m "feat(mentor): admin preview + send mentorship welcome/reminder email"

Task 5: Admin "Send welcome/reminder" button (wired into the round page)

Files:

  • Create: src/components/admin/round/send-mentorship-welcome-button.tsx

  • Modify: src/app/(admin)/admin/rounds/[roundId]/page.tsx (imports near line 127; Notifications grid lines 14411445)

  • Step 1: Create the button component

src/components/admin/round/send-mentorship-welcome-button.tsx:

'use client'

import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Mail } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'

interface SendMentorshipWelcomeButtonProps {
  roundId: string
}

export function SendMentorshipWelcomeButton({ roundId }: SendMentorshipWelcomeButtonProps) {
  const [open, setOpen] = useState(false)
  const [customMessage, setCustomMessage] = useState<string | undefined>()

  const preview = trpc.mentor.previewMentorshipWelcome.useQuery(
    { roundId, customNote: customMessage },
    { enabled: open },
  )

  const sendMutation = trpc.mentor.sendMentorshipWelcome.useMutation({
    onSuccess: (data) => {
      toast.success(
        `Sent ${data.sent} email${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`,
      )
      setOpen(false)
    },
    onError: (err) => toast.error(err.message),
  })

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-sky-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
      >
        <Mail className="h-5 w-5 text-sky-600 mt-0.5 shrink-0" />
        <div>
          <p className="text-sm font-medium">Send Welcome / Reminder</p>
          <p className="text-xs text-muted-foreground mt-0.5">
            Email all mentors &amp; teams how to use mentorship
          </p>
        </div>
      </button>

      <EmailPreviewDialog
        open={open}
        onOpenChange={setOpen}
        title="Send Mentorship Welcome / Reminder"
        description="Emails every mentor and team member in this round with their assignment and how to use the mentorship features. You can add an optional note below."
        recipientCount={preview.data?.recipientCount ?? 0}
        previewHtml={preview.data?.html}
        isPreviewLoading={preview.isLoading}
        onSend={(msg) => sendMutation.mutate({ roundId, customNote: msg })}
        isSending={sendMutation.isPending}
        onRefreshPreview={(msg) => setCustomMessage(msg)}
      />
    </>
  )
}
  • Step 2: Import the button in the round page

In src/app/(admin)/admin/rounds/[roundId]/page.tsx, after line 127 (the BulkInviteButton import), add:

import { SendMentorshipWelcomeButton } from '@/components/admin/round/send-mentorship-welcome-button'
  • Step 3: Render it (mentoring rounds only) in the Notifications grid

Replace the Notifications grid (lines 14411445) so it reads:

                    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
                      <NotifyAdvancedButton roundId={roundId} />
                      <NotifyRejectedButton roundId={roundId} />
                      <BulkInviteButton roundId={roundId} />
                      {isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
                    </div>
  • Step 4: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 5: Manual verification (no component-test harness exists in this repo)

Run: npm run dev, log in as an admin, open a MENTORING round's detail page. Confirm: the "Send Welcome / Reminder" card appears in Notifications (and is absent on non-mentoring rounds). Click it → the dialog opens, shows a recipient count, renders the stacked Mentor version / Team version preview, updates when you type a note and refresh, and the toast reports a count on send. (To eyeball the raw HTML earlier/offline, call the previewMentorshipWelcome query via the tRPC panel or temporarily write pv.html to a file in the test.)

  • Step 6: Commit
git add src/components/admin/round/send-mentorship-welcome-button.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "feat(admin): send mentorship welcome/reminder button on mentoring rounds"

Task 6: Mentor "Email all team members" button

Files:

  • Modify: src/app/(mentor)/mentor/projects/[id]/page.tsx (Team Members card; CardContent begins around line 350, members at 332427; Button and Mail already imported)

  • Step 1: Add the button at the top of the Team Members CardContent

Immediately after the <CardContent className="space-y-4"> opening tag of the Team Members card, insert:

            {(() => {
              const emails = (project.teamMembers ?? [])
                .map((m) => m.user.email)
                .filter((e): e is string => !!e)
              if (emails.length === 0) return null
              const mailto = `mailto:${emails.join(',')}?subject=${encodeURIComponent(
                `MOPC Mentorship — ${project.title}`,
              )}`
              return (
                <div className="flex justify-end">
                  <Button variant="outline" size="sm" asChild>
                    <a href={mailto}>
                      <Mail className="mr-2 h-4 w-4" />
                      Email all team members
                    </a>
                  </Button>
                </div>
              )
            })()}
  • Step 2: Typecheck

Run: npm run typecheck Expected: PASS. (Button is imported at line 14; Mail at line 46; project.title and project.teamMembers[].user.email are part of getProjectDetail.)

  • Step 3: Manual verification

In npm run dev, open a mentor's project detail page. Confirm the "Email all team members" button shows in the Team Members card, and clicking it opens the OS mail client with every team member's address in the To: field and the subject pre-filled. Confirm it is hidden when a project has no team members.

  • Step 4: Commit
git add "src/app/(mentor)/mentor/projects/[id]/page.tsx"
git commit -m "feat(mentor): email all team members button on project detail"

Task 7: Full verification

  • Step 1: Run the full test suite

Run: npx vitest run Expected: PASS (all existing tests + the two new files). Pay attention that tests/unit/mentor-email-deferral.test.ts still passes.

  • Step 2: Typecheck + lint + build

Run: npm run typecheck && npm run lint && npm run build Expected: all PASS (CLAUDE.md requires a green build before push).

  • Step 3: Final commit (if lint/format changed anything)
git add -A
git commit -m "chore(mentor): lint/format for mentorship comms feature" || echo "nothing to commit"

Self-Review Notes

  • Spec coverage: Email-all button → Task 6. Upgraded auto-intro emails (instructions + contacts) → Tasks 13. Re-sendable manual reminder with optional note → Task 4. Tailored content with embedded contact emails → Task 1. Preview → Task 4 (query) + Task 5 (dialog). Both-audience default, no toggle → Task 4. Email in To: → Task 6. Teammates + mentor in team email → Tasks 1 & 4.
  • Type consistency: getMentorBulkAssignmentTemplate(name, projects[{title,url,teamMembers?}], dashUrl, customNote?), getTeamMentorIntroductionTemplate(recipientName, projectTitle, mentors, workspaceUrl, teammates?, customNote?), senders return Promise<boolean>, procedures return { html, recipientCount } and { sent, failed } — used consistently by the dialog (recipientCount, data.sent, data.failed).
  • No placeholders: every code step contains full code; instruction copy is final text.