Merge: mentorship comms + welcome/reminder email
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m8s

- Email-all-team-members button for mentors
- Upgraded round-open emails (instructions + contact addresses)
- Admin re-sendable welcome/reminder blast with live preview
- New tRPC: mentor.previewMentorshipWelcome / sendMentorshipWelcome

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-01 17:04:36 +02:00
10 changed files with 1844 additions and 44 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
# Mentorship Communications & Welcome/Reminder Email — Design
- **Date:** 2026-06-01
- **Status:** Approved (pending spec review)
- **Author:** Matt + Claude
- **Topic:** Make mentor↔team contact effortless and add a re-sendable, instructional "welcome/reminder" email for mentoring rounds.
## Context
MOPC already has a working mentorship feature:
- **Two-way in-app messaging** exists (`MentorMessage` model; `WorkspaceChat` + `MentorChat` components; `trpc.mentor.sendMessage` / `getMessages` and `trpc.applicant.sendMentorMessage` / `getMentorMessages`). Mentors are auto-notified when applicants write.
- **Contact emails are already visible**: mentors see each team member's email as individual `mailto:` links (`src/app/(mentor)/mentor/projects/[id]/page.tsx`); applicants see their mentor's name+email (`src/app/(applicant)/applicant/mentor/page.tsx`) and teammates' emails (`src/app/(applicant)/applicant/team/page.tsx`).
- **Round-open auto emails already fire**: flipping a `MENTORING` round draft→active sends a coalesced *"you've been assigned to N projects"* email to each mentor (`getMentorBulkAssignmentTemplate` / `sendMentorBulkAssignmentEmail`) and a *"meet your mentors"* intro to each team (`getTeamMentorIntroductionTemplate` / `sendTeamMentorIntroductionEmail`). These are one-time, gated by `MentorAssignment.notificationSentAt` and `MentorAssignment.teamIntroducedAt` (`src/server/services/round-engine.ts`).
Two gaps remain:
1. There is **no single "email all team members"** affordance for mentors — only per-person `mailto:` links.
2. The round-open emails **don't explain how to use the mentorship features**, and there is **no way to re-send** them later as a reminder.
## Goals
- A mentor can email their whole team in one click (opens their mail client, all members in `To:`).
- The round-open assignment emails are **upgraded in place** to include (a) the relevant contact emails and (b) how-to-use-the-mentorship-features instructions.
- An admin can **re-send** that same email on demand (a "welcome/reminder" blast) to all mentors + teams in a mentoring round, with an optional custom note.
- The admin can **preview** the exact email (mentor + team versions) before sending.
## Non-goals
- No new in-app messaging surfaces (the chat already exists).
- No new email-provider infrastructure (reuse `src/lib/email.ts` wrapper, helpers, throttling, `NotificationLog`).
- No mentors-only / teams-only targeting toggle for v1 — the reminder sends to **both** audiences. (Can be added later if needed.)
## Feature 1 — Mentor "Email all team members" button
- **Location:** `src/app/(mentor)/mentor/projects/[id]/page.tsx`, in the existing Team Members card, alongside the per-member `mailto:` links.
- **Behavior:** builds `mailto:<comma-joined emails>?subject=...` with **all active team members in `To:`** (per decision), subject pre-filled `MOPC Mentorship — {project title}`. Clicking opens the mentor's default mail app.
- **Edge cases:** filter out blank/missing emails defensively (schema makes `User.email` required+non-null, but be safe); hide the button when the team has zero emailable members.
- **Scope:** pure client-side; no backend changes.
## Feature 2 — Unified mentorship welcome/reminder email
### Decision: upgrade in place, don't duplicate
Rather than send a second email on round-open, the **existing** two templates are enhanced so they carry the instructions + contact emails. The same template code is reused by both trigger paths below. One email per audience; one source of truth.
### Content — Mentor version (coalesced per mentor, across their projects in the round)
- Greeting by mentor name.
- Optional custom note (rendered in an info box near the top) — only present on the manual reminder path.
- For **each** assigned project: project title (linked) + the **team members listed with name + email**.
- "How to mentor on MOPC" instructions block: where the workspace chat lives, file sharing, the mentor dashboard.
- CTA → Mentor Dashboard.
### Content — Team version (per project)
- Greeting by recipient name.
- Optional custom note (info box) — manual path only.
- The assigned **mentor(s) listed with name + email**.
- The team's **own members listed with email** (per decision: include teammates too).
- "How to work with your mentor" instructions block: where the in-app chat is, how to reach the mentor, what to expect.
- CTA → mentoring page.
Both reuse `getEmailWrapper()` and existing helpers (`sectionTitle`, `paragraph`, `ctaButton`, `infoBox`, `escapeHtml`) for consistent branding.
### Trigger path A — auto on round-open (existing flow, upgraded content)
- `src/server/services/round-engine.ts` draft→active flow keeps its one-time semantics (`notificationSentAt` / `teamIntroducedAt` gating) and coalescing.
- It now passes the additional data the upgraded templates need: team-member name+email for the mentor email, and mentor name+email + teammate emails for the team email.
- No custom note on this path.
### Trigger path B — manual reminder button (admin, on demand)
- New `adminProcedure`: `mentor.sendMentorshipWelcome({ roundId, customNote?: string })`.
- Resolves **all current** active assignments for the round (`droppedAt: null`) → groups by mentor → sends mentor emails; resolves all projects with assignments → sends team emails to all members.
- **Ignores** `notificationSentAt` / `teamIntroducedAt` (deliberate re-send). Does **not** mutate those flags.
- Throttled + fire-and-forget like existing bulk sends; writes `NotificationLog` rows + a `DecisionAuditLog`/audit entry.
- Returns counts: `{ mentorCount, teamMemberCount, teamCount }` for the success toast.
### Preview
- New query: `mentor.previewMentorshipWelcome({ roundId, customNote?: string })``{ mentor: { subject, html }, team: { subject, html } }`.
- Calls the **same** template functions used by the real send.
- Picks a representative recipient: first mentor with assignments + first project/team in the round. If the round has none yet, returns clearly-labeled sample-data output so the layout is still previewable.
- Rendered in the send dialog inside a sandboxed `<iframe srcDoc={html}>` (isolates email CSS from the app), with **Mentor / Team** sub-tabs. The custom-note textarea updates the preview live (debounced).
### Admin UI
- New component `src/components/admin/round/send-mentorship-welcome-button.tsx`:
- Lives in the round detail page's **Notifications** section (`src/app/(admin)/admin/rounds/[roundId]/page.tsx`, near `NotifyAdvancedButton` / `NotifyRejectedButton` / `BulkInviteButton`), rendered **only when the round is `MENTORING`**.
- Opens a dialog: recipient summary ("N mentors · M team members across K teams"), optional custom-note textarea, live Preview (Mentor/Team tabs), and a Send button with confirmation.
## Files touched
| File | Change |
|---|---|
| `src/app/(mentor)/mentor/projects/[id]/page.tsx` | Add "Email all team members" button (mailto, all in To:) |
| `src/lib/email.ts` | Enhance `getMentorBulkAssignmentTemplate` + `getTeamMentorIntroductionTemplate` (contacts, instructions, optional `customNote`); update `sendMentorBulkAssignmentEmail` / `sendTeamMentorIntroductionEmail` signatures + all call sites |
| `src/server/services/round-engine.ts` | Pass team-member/mentor emails into upgraded templates on round-open |
| `src/server/routers/mentor.ts` | New `sendMentorshipWelcome` (adminProcedure) + `previewMentorshipWelcome` (adminProcedure query) |
| `src/components/admin/round/send-mentorship-welcome-button.tsx` (new) | Dialog: counts, custom note, live iframe preview, send |
| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Wire the button into the Notifications section, gated to mentoring rounds |
## Implementation ordering note
Build the templates first and render both to standalone `.html` files (and/or screenshots) for copy review **before** wiring the send path — gives an early visual check with zero throwaway work.
## Testing
- **Template unit tests** (`src/lib/email.ts` fns return `{ subject, html, text }`, easy to assert): mentor email contains each team member's email + instructions block; team email contains mentor email(s) + teammate emails + instructions; custom note appears when passed, absent when not.
- **tRPC test** for `sendMentorshipWelcome` on a seeded mentoring round: correct recipient resolution and returned counts; does not flip the one-time flags.
- **tRPC test** for `previewMentorshipWelcome`: returns non-empty mentor + team HTML for a seeded round; sample-data fallback for an empty round.
## Decisions (resolved during brainstorming)
1. Upgrade existing intro emails in place (single source of truth), reused by both auto-open and the manual reminder; fallback would have been a standalone manual-only blast.
2. Tailored content per audience (mentor vs team), **with contact emails embedded** in the relevant spot.
3. Manual reminder: fixed branded template **+ optional custom note**.
4. "Email all" button: **all members in `To:`**.
5. Team email includes **both** the mentor's email and teammates' emails.
6. Manual reminder sends to **both** audiences (no per-audience toggle in v1).
7. Preview via an in-app button (live, real-data, iframe) rather than pasted static HTML.

View File

@@ -125,6 +125,7 @@ import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-sec
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { SendMentorshipWelcomeButton } from '@/components/admin/round/send-mentorship-welcome-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
@@ -1442,6 +1443,7 @@ export default function RoundDetailPage() {
<NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} />
{isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
</div>
</div>
)}

View File

@@ -344,6 +344,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{(() => {
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>
)
})()}
{/* Team Lead */}
{teamLead && (
<div className="p-4 rounded-lg border bg-muted/30">

View File

@@ -0,0 +1,61 @@
'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)}
/>
</>
)
}

View File

@@ -2832,11 +2832,13 @@ export async function sendMentorTeamAssignmentEmail(
}
}
function getTeamMentorIntroductionTemplate(
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 =
@@ -2846,40 +2848,73 @@ function getTeamMentorIntroductionTemplate(
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'
const mentorTextLines = mentors
.map(
(m) => `${m.name ?? 'Mentor'}${m.email}`,
)
.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,
'',
'You can chat with them, share files, and track milestones in your mentor workspace:',
workspaceUrl,
'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.',
'',
'Feel free to reach out to them directly by email as well.',
`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>
<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>
@@ -2890,16 +2925,16 @@ function getTeamMentorIntroductionTemplate(
</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 20px;font-size:14px;">${mentorHtmlList}</table>
<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>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
You can chat with them, share files, and track milestones in the workspace — or reach out to them directly by email.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
@@ -2924,16 +2959,20 @@ export async function sendTeamMentorIntroductionEmail(
projectTitle: string,
projectId: string,
mentors: { name: string | null; email: string }[],
): Promise<void> {
teammates?: { name: string | null; email: string }[],
customNote?: string,
): Promise<boolean> {
try {
if (mentors.length === 0) return
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
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,
@@ -2941,15 +2980,22 @@ export async function sendTeamMentorIntroductionEmail(
text: template.text,
html: template.html,
})
return true
} catch (error) {
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
return false
}
}
function getMentorBulkAssignmentTemplate(
export function getMentorBulkAssignmentTemplate(
name: string,
projects: { title: string; url: string }[],
projects: {
title: string
url: string
teamMembers?: { name: string | null; email: string }[]
}[],
mentorDashboardUrl: string,
customNote?: string,
): EmailTemplate {
const count = projects.length
const subject =
@@ -2958,32 +3004,65 @@ function getMentorBulkAssignmentTemplate(
: `You've been assigned to ${count} new MOPC projects`
const greeting = name ? `Hi ${name},` : 'Hi there,'
const textLines = projects
.map((p) => `${p.title}${p.url}`)
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:`,
'',
textLines,
...textBlocks,
'',
'You may have co-mentors on these teams — you can collaborate together in each project workspace.',
'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(
(p) =>
`<li style="margin:6px 0;"><a href="${p.url}" style="color:#053d57;text-decoration:none;font-weight:600;">${escapeHtml(p.title)}</a></li>`,
(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>
@@ -2994,14 +3073,13 @@ function getMentorBulkAssignmentTemplate(
</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>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
You may have co-mentors on these teams — you can collaborate together in each project workspace.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
@@ -3023,23 +3101,23 @@ function getMentorBulkAssignmentTemplate(
export async function sendMentorBulkAssignmentEmail(
email: string,
name: string | null,
projects: { id: string; title: string }[],
): Promise<void> {
projects: { id: string; title: string; teamMembers?: { name: string | null; email: string }[] }[],
customNote?: string,
): Promise<boolean> {
try {
if (projects.length === 0) return
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
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`,
)
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
}
}

View File

@@ -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 }
}),
})

View File

@@ -232,7 +232,13 @@ export async function activateRound(
id: true,
mentorId: true,
mentor: { select: { name: true, email: true } },
project: { select: { id: true, title: true } },
project: {
select: {
id: true,
title: true,
teamMembers: { select: { user: { select: { name: true, email: true } } } },
},
},
},
})
const perMentor = new Map<
@@ -241,7 +247,7 @@ export async function activateRound(
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
}
>()
for (const a of pendingAssignments) {
@@ -253,7 +259,13 @@ export async function activateRound(
projects: [],
}
bucket.assignmentIds.push(a.id)
bucket.projects.push({ id: a.project.id, title: a.project.title })
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()) {
@@ -336,8 +348,13 @@ export async function activateRound(
})
}
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) {
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors)
const teammates = allMembers.filter((m) => m.email !== email)
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates)
}
// Stamp every mentor-assignment row so re-activation doesn't re-send.

View File

@@ -0,0 +1,74 @@
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',
'Reach out <b>now</b> & soon',
)
expect(t.html).toContain('bob@team.com')
expect(t.html).toContain('How to mentor on MOPC')
// The admin-supplied custom note must be HTML-escaped in the HTML body.
expect(t.html).toContain('&lt;b&gt;')
expect(t.html).not.toContain('<b>now</b>')
expect(t.text).toContain('bob@team.com')
expect(t.text).toContain('How to mentor on MOPC')
// The plain-text body keeps the raw note unescaped.
expect(t.text).toContain('Reach out <b>now</b> & soon')
})
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!')
// Mirror coverage on the plain-text path.
expect(t.text).toContain('alice@mentor.com')
expect(t.text).toContain('carol@team.com')
expect(t.text).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')
})
})

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendMentorBulkAssignmentEmail: vi.fn(async () => true),
sendTeamMentorIntroductionEmail: vi.fn(async () => true),
}
})
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
describe('mentor.sendMentorshipWelcome / previewMentorshipWelcome', () => {
let programId: string
const userIds: string[] = []
let roundId: string
let memberEmail: string
let mentorEmail: string
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(program.id)
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
status: 'ROUND_ACTIVE',
})
roundId = round.id
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const mentor = await createTestUser('MENTOR')
const member = await createTestUser('APPLICANT')
mentorEmail = mentor.email
memberEmail = member.email
userIds.push(mentor.id, member.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: member.id, role: 'LEAD' },
})
await prisma.mentorAssignment.create({
data: { projectId: project.id, mentorId: mentor.id, method: 'MANUAL' },
})
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('sends to mentors and team members and reports counts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const res = await caller.sendMentorshipWelcome({ roundId, customNote: 'Reminder!' })
expect(res.sent).toBeGreaterThan(0)
expect(sendMentorBulkAssignmentEmail).toHaveBeenCalled()
expect(sendTeamMentorIntroductionEmail).toHaveBeenCalled()
})
it('does NOT stamp the one-time flags (re-sendable reminder)', async () => {
const assignment = await prisma.mentorAssignment.findFirst({
where: { project: { projectRoundStates: { some: { roundId } } } },
})
expect(assignment?.notificationSentAt).toBeNull()
expect(assignment?.teamIntroducedAt).toBeNull()
})
it('preview returns non-empty mentor + team HTML with real contacts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const pv = await caller.previewMentorshipWelcome({ roundId })
expect(pv.recipientCount).toBeGreaterThan(0)
expect(pv.html).toContain('Mentor version')
expect(pv.html).toContain('Team version')
expect(pv.html).toContain(memberEmail)
expect(pv.html).toContain(mentorEmail)
})
})