feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s

Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
  toolbar that appears when 1+ rows are selected with an "Assign mentor…"
  CTA and Clear. Dialog lists the mentor pool with search (name/email/
  country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
  and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
  one mentor to many projects in a transaction; idempotent on the per-pair
  `(projectId, mentorId)` unique; per-project in-app notifications still
  fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
  getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
  so the page reflects the new state without a refresh.

Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
  assigned project + workspace links) used by `mentor.bulkAssign` and
  `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
  now emails mentors at the end of the batch, one combined email per
  mentor regardless of how many projects they received.

Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
  name + email and a link to the workspace, so teams can reach out
  directly.
- `activateRound` (round-engine) fires the introduction for every project
  in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
  fire the introduction immediately when the project's MENTORING round is
  already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
  (migration 20260526114936) — independent from `notificationSentAt` so
  pre-existing mentor-side stamps don't suppress the team-side email.
This commit is contained in:
Matt
2026-05-26 14:04:32 +02:00
parent 921019aaa4
commit 195fc787a9
7 changed files with 1025 additions and 7 deletions

View File

@@ -2832,6 +2832,217 @@ export async function sendMentorTeamAssignmentEmail(
}
}
function getTeamMentorIntroductionTemplate(
recipientName: string | null,
projectTitle: string,
mentors: { name: string | null; email: string }[],
workspaceUrl: 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 text = [
greeting,
'',
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,
'',
'You can chat with them, share files, and track milestones in your mentor workspace:',
workspaceUrl,
'',
'Feel free to reach out to them directly by email as well.',
'',
'The MOPC team',
].join('\n')
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 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>
<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>
<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
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Introduce a project team to their assigned mentor(s), with each mentor's
* name + email so the team can reach out directly. Sent when the MENTORING
* round opens AND any time a mentor is added to a project whose mentoring
* round is already open. Never throws.
*/
export async function sendTeamMentorIntroductionEmail(
recipientEmail: string,
recipientName: string | null,
projectTitle: string,
projectId: string,
mentors: { name: string | null; email: string }[],
): Promise<void> {
try {
if (mentors.length === 0) return
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
const workspaceUrl = `${baseUrl}/applicant/mentor`
const template = getTeamMentorIntroductionTemplate(
recipientName,
projectTitle,
mentors,
workspaceUrl,
)
await sendEmail({
to: recipientEmail,
subject: template.subject,
text: template.text,
html: template.html,
})
} catch (error) {
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
}
}
function getMentorBulkAssignmentTemplate(
name: string,
projects: { title: string; url: string }[],
mentorDashboardUrl: 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 textLines = projects
.map((p) => `${p.title}${p.url}`)
.join('\n')
const text = [
greeting,
'',
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,
'',
'You may have co-mentors on these teams — you can collaborate together in each project workspace.',
'',
`Open your mentor dashboard: ${mentorDashboardUrl}`,
'',
'The MOPC team',
].join('\n')
const htmlList = projects
.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>`,
)
.join('')
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>
<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>
<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
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Send a coalesced mentor-assignment email when one mentor receives multiple
* project assignments in a single bulk operation. Caller passes the list of
* NEW assignments (already filtered to exclude any whose notificationSentAt
* was previously set). Never throws.
*/
export async function sendMentorBulkAssignmentEmail(
email: string,
name: string | null,
projects: { id: string; title: string }[],
): Promise<void> {
try {
if (projects.length === 0) return
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
const enriched = projects.map((p) => ({
title: p.title,
url: `${baseUrl}/mentor/workspace/${p.id}`,
}))
const template = getMentorBulkAssignmentTemplate(
name || '',
enriched,
`${baseUrl}/mentor`,
)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} catch (error) {
console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
}
}
// =============================================================================
// Mentor change requests (PR 8) — admin notification when an applicant or admin
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).