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

1114 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```typescript
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:
```typescript
function getBaseUrl(): string {
```
to:
```typescript
export function getBaseUrl(): string {
```
- [ ] **Step 4: Replace `getMentorBulkAssignmentTemplate` (lines 29493015) with the enhanced, exported version**
```typescript
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**
```typescript
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**
```bash
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)**
```typescript
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)**
```typescript
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**
```bash
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:
```typescript
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:
```typescript
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:
```typescript
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**
```bash
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`:
```typescript
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):
```typescript
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)**
```typescript
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**
```bash
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`:
```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:
```typescript
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:
```tsx
<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**
```bash
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:
```tsx
{(() => {
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**
```bash
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)**
```bash
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.