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
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:
211
src/lib/email.ts
211
src/lib/email.ts
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user