Merge: mentorship comms + welcome/reminder email
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m8s
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:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -125,6 +125,7 @@ import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-sec
|
|||||||
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
|
||||||
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
|
||||||
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-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 { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||||
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
|
||||||
|
|
||||||
@@ -1442,6 +1443,7 @@ export default function RoundDetailPage() {
|
|||||||
<NotifyAdvancedButton roundId={roundId} />
|
<NotifyAdvancedButton roundId={roundId} />
|
||||||
<NotifyRejectedButton roundId={roundId} />
|
<NotifyRejectedButton roundId={roundId} />
|
||||||
<BulkInviteButton roundId={roundId} />
|
<BulkInviteButton roundId={roundId} />
|
||||||
|
{isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -344,6 +344,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 */}
|
{/* Team Lead */}
|
||||||
{teamLead && (
|
{teamLead && (
|
||||||
<div className="p-4 rounded-lg border bg-muted/30">
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
|||||||
@@ -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 & 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
src/lib/email.ts
152
src/lib/email.ts
@@ -2832,11 +2832,13 @@ export async function sendMentorTeamAssignmentEmail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamMentorIntroductionTemplate(
|
export function getTeamMentorIntroductionTemplate(
|
||||||
recipientName: string | null,
|
recipientName: string | null,
|
||||||
projectTitle: string,
|
projectTitle: string,
|
||||||
mentors: { name: string | null; email: string }[],
|
mentors: { name: string | null; email: string }[],
|
||||||
workspaceUrl: string,
|
workspaceUrl: string,
|
||||||
|
teammates?: { name: string | null; email: string }[],
|
||||||
|
customNote?: string,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const count = mentors.length
|
const count = mentors.length
|
||||||
const subject =
|
const subject =
|
||||||
@@ -2846,40 +2848,73 @@ function getTeamMentorIntroductionTemplate(
|
|||||||
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'
|
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'
|
||||||
|
|
||||||
const mentorTextLines = mentors
|
const mentorTextLines = mentors
|
||||||
.map(
|
.map((m) => ` • ${m.name ?? 'Mentor'} — ${m.email}`)
|
||||||
(m) => ` • ${m.name ?? 'Mentor'} — ${m.email}`,
|
|
||||||
)
|
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
const teammateTextLines =
|
||||||
|
teammates && teammates.length > 0
|
||||||
|
? ['', 'Your team:', ...teammates.map((t) => ` • ${t.name ?? 'Team member'} — ${t.email}`)]
|
||||||
|
: []
|
||||||
|
|
||||||
const text = [
|
const text = [
|
||||||
greeting,
|
greeting,
|
||||||
'',
|
'',
|
||||||
|
...(customNote ? [customNote, ''] : []),
|
||||||
count === 1
|
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 a mentor:`
|
||||||
: `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`,
|
: `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`,
|
||||||
'',
|
'',
|
||||||
mentorTextLines,
|
mentorTextLines,
|
||||||
|
...teammateTextLines,
|
||||||
'',
|
'',
|
||||||
'You can chat with them, share files, and track milestones in your mentor workspace:',
|
'Working with your mentor:',
|
||||||
workspaceUrl,
|
' - 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',
|
'The MOPC team',
|
||||||
].join('\n')
|
].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
|
const mentorHtmlList = mentors
|
||||||
.map(
|
.map(
|
||||||
(m) => `
|
(m) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px 0;font-weight:600;color:#0f172a;">${escapeHtml(m.name ?? 'Mentor')}</td>
|
<td style="padding:6px 0;font-weight:600;color:#0f172a;">${escapeHtml(m.name ?? 'Mentor')}</td>
|
||||||
<td style="padding:6px 0;">
|
<td style="padding:6px 0;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td>
|
||||||
<a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a>
|
|
||||||
</td>
|
|
||||||
</tr>`,
|
</tr>`,
|
||||||
)
|
)
|
||||||
.join('')
|
.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 = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -2890,16 +2925,16 @@ function getTeamMentorIntroductionTemplate(
|
|||||||
</div>
|
</div>
|
||||||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
<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 style="margin-top:0;">${recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,'}</p>
|
||||||
|
${customNoteHtml}
|
||||||
<p>${count === 1
|
<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 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>
|
: `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;">
|
<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>
|
<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>
|
||||||
<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>
|
||||||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
Monaco Ocean Protection Challenge
|
Monaco Ocean Protection Challenge
|
||||||
@@ -2924,16 +2959,20 @@ export async function sendTeamMentorIntroductionEmail(
|
|||||||
projectTitle: string,
|
projectTitle: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
mentors: { name: string | null; email: string }[],
|
mentors: { name: string | null; email: string }[],
|
||||||
): Promise<void> {
|
teammates?: { name: string | null; email: string }[],
|
||||||
|
customNote?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (mentors.length === 0) return
|
if (mentors.length === 0) return false
|
||||||
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
|
const baseUrl = getBaseUrl()
|
||||||
const workspaceUrl = `${baseUrl}/applicant/mentor`
|
const workspaceUrl = `${baseUrl}/applicant/mentor`
|
||||||
const template = getTeamMentorIntroductionTemplate(
|
const template = getTeamMentorIntroductionTemplate(
|
||||||
recipientName,
|
recipientName,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
mentors,
|
mentors,
|
||||||
workspaceUrl,
|
workspaceUrl,
|
||||||
|
teammates,
|
||||||
|
customNote,
|
||||||
)
|
)
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
@@ -2941,15 +2980,22 @@ export async function sendTeamMentorIntroductionEmail(
|
|||||||
text: template.text,
|
text: template.text,
|
||||||
html: template.html,
|
html: template.html,
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
|
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMentorBulkAssignmentTemplate(
|
export function getMentorBulkAssignmentTemplate(
|
||||||
name: string,
|
name: string,
|
||||||
projects: { title: string; url: string }[],
|
projects: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
teamMembers?: { name: string | null; email: string }[]
|
||||||
|
}[],
|
||||||
mentorDashboardUrl: string,
|
mentorDashboardUrl: string,
|
||||||
|
customNote?: string,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const count = projects.length
|
const count = projects.length
|
||||||
const subject =
|
const subject =
|
||||||
@@ -2958,32 +3004,65 @@ function getMentorBulkAssignmentTemplate(
|
|||||||
: `You've been assigned to ${count} new MOPC projects`
|
: `You've been assigned to ${count} new MOPC projects`
|
||||||
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||||
|
|
||||||
const textLines = projects
|
const textBlocks = projects.map((p) => {
|
||||||
.map((p) => ` • ${p.title} — ${p.url}`)
|
const members =
|
||||||
|
p.teamMembers && p.teamMembers.length > 0
|
||||||
|
? '\n' +
|
||||||
|
p.teamMembers
|
||||||
|
.map((m) => ` - ${m.name ?? 'Team member'}: ${m.email}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
return ` • ${p.title} — ${p.url}${members}`
|
||||||
|
})
|
||||||
const text = [
|
const text = [
|
||||||
greeting,
|
greeting,
|
||||||
'',
|
'',
|
||||||
|
...(customNote ? [customNote, ''] : []),
|
||||||
count === 1
|
count === 1
|
||||||
? `You have been assigned as a mentor to a new project:`
|
? `You have been assigned as a mentor to a new project:`
|
||||||
: `You have been assigned as a mentor to ${count} new projects:`,
|
: `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}`,
|
`Open your mentor dashboard: ${mentorDashboardUrl}`,
|
||||||
'',
|
'',
|
||||||
'The MOPC team',
|
'The MOPC team',
|
||||||
].join('\n')
|
].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
|
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(
|
.map(
|
||||||
(p) =>
|
(m) =>
|
||||||
`<li style="margin:6px 0;"><a href="${p.url}" style="color:#053d57;text-decoration:none;font-weight:600;">${escapeHtml(p.title)}</a></li>`,
|
`<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('')
|
.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 = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -2994,14 +3073,13 @@ function getMentorBulkAssignmentTemplate(
|
|||||||
</div>
|
</div>
|
||||||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
<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 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>
|
<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>
|
<ul style="padding-left:20px;margin:12px 0 20px;">${htmlList}</ul>
|
||||||
|
${instructionsHtml}
|
||||||
<p style="margin-top:24px;">
|
<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>
|
<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>
|
||||||
<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>
|
||||||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
Monaco Ocean Protection Challenge
|
Monaco Ocean Protection Challenge
|
||||||
@@ -3023,23 +3101,23 @@ function getMentorBulkAssignmentTemplate(
|
|||||||
export async function sendMentorBulkAssignmentEmail(
|
export async function sendMentorBulkAssignmentEmail(
|
||||||
email: string,
|
email: string,
|
||||||
name: string | null,
|
name: string | null,
|
||||||
projects: { id: string; title: string }[],
|
projects: { id: string; title: string; teamMembers?: { name: string | null; email: string }[] }[],
|
||||||
): Promise<void> {
|
customNote?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return false
|
||||||
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
|
const baseUrl = getBaseUrl()
|
||||||
const enriched = projects.map((p) => ({
|
const enriched = projects.map((p) => ({
|
||||||
title: p.title,
|
title: p.title,
|
||||||
url: `${baseUrl}/mentor/workspace/${p.id}`,
|
url: `${baseUrl}/mentor/workspace/${p.id}`,
|
||||||
|
teamMembers: p.teamMembers,
|
||||||
}))
|
}))
|
||||||
const template = getMentorBulkAssignmentTemplate(
|
const template = getMentorBulkAssignmentTemplate(name || '', enriched, `${baseUrl}/mentor`, customNote)
|
||||||
name || '',
|
|
||||||
enriched,
|
|
||||||
`${baseUrl}/mentor`,
|
|
||||||
)
|
|
||||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
|
console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
sendMentorChangeRequestEmail,
|
sendMentorChangeRequestEmail,
|
||||||
sendMentorTeamAssignmentEmail,
|
sendMentorTeamAssignmentEmail,
|
||||||
sendTeamMentorIntroductionEmail,
|
sendTeamMentorIntroductionEmail,
|
||||||
|
getMentorBulkAssignmentTemplate,
|
||||||
|
getTeamMentorIntroductionTemplate,
|
||||||
|
getBaseUrl,
|
||||||
} from '@/lib/email'
|
} from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
@@ -3287,4 +3290,208 @@ export const mentorRouter = router({
|
|||||||
|
|
||||||
return updated
|
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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -232,7 +232,13 @@ export async function activateRound(
|
|||||||
id: true,
|
id: true,
|
||||||
mentorId: true,
|
mentorId: true,
|
||||||
mentor: { select: { name: true, email: 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<
|
const perMentor = new Map<
|
||||||
@@ -241,7 +247,7 @@ export async function activateRound(
|
|||||||
email: string | null
|
email: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
assignmentIds: string[]
|
assignmentIds: string[]
|
||||||
projects: { id: string; title: string }[]
|
projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
for (const a of pendingAssignments) {
|
for (const a of pendingAssignments) {
|
||||||
@@ -253,7 +259,13 @@ export async function activateRound(
|
|||||||
projects: [],
|
projects: [],
|
||||||
}
|
}
|
||||||
bucket.assignmentIds.push(a.id)
|
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)
|
perMentor.set(a.mentorId, bucket)
|
||||||
}
|
}
|
||||||
for (const bucket of perMentor.values()) {
|
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) {
|
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.
|
// Stamp every mentor-assignment row so re-activation doesn't re-send.
|
||||||
|
|||||||
74
tests/unit/mentor-welcome-email.test.ts
Normal file
74
tests/unit/mentor-welcome-email.test.ts
Normal 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('<b>')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
107
tests/unit/mentorship-welcome-send.test.ts
Normal file
107
tests/unit/mentorship-welcome-send.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user