Compare commits

...

6 Commits

Author SHA1 Message Date
Matt
03526fca97 fix(mentor): defer in-app-notification emails when mentoring round is draft
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Mentor-assignment flows (mentor.assign, autoAssign, bulkAssign,
bulkAutoAssign, autoAssignBulkForRound) call createNotification and
notifyProjectTeam for MENTEE_ASSIGNED / MENTOR_ASSIGNED. Both
notification types have NotificationEmailSetting.sendEmail = true, so
the notification system fires its own styled email in addition to the
explicit mentor-team / coalesced emails on the same code path. The
earlier defer-emails-until-round-open fix only gated the explicit
sendMentorBulkAssignmentEmail / sendMentorTeamAssignmentEmail calls;
this parallel email path kept firing immediately at every assignment.

Result on prod 2026-05-26: Camille Lopez (assigned to 9 projects via
two bulk_assigns) received 7 emails at 15:04 + 1 at 15:32 from the
notification-system path during draft, plus 1 coalesced email at the
18:20 round activation = 9 sends instead of 1. Every PEARL team
member (and equivalents on other teams) received 3 emails for the
same reason.

Fix
- Add `skipEmail?: boolean` to CreateNotificationParams,
  createNotification, createBulkNotifications, and (via spread)
  notifyProjectTeam. When true the in-app notification row still
  fires but the parallel email send is suppressed; the coalesced
  mentor email and team intro at activateRound time remain the
  single source of email truth.
- Wire it up in every mentor-assignment site: compute the existing
  shouldDeferEmailsForProject gate once before the createNotification
  / notifyProjectTeam calls and pass `skipEmail: deferThisEmail`.
  bulkAssign precomputes draftProjectIds for the whole batch.
  autoAssignBulkForRound uses the round's status directly.
- New regression suite (mentor-email-deferral.test.ts, 3 cases):
  vi.mocks @/lib/email, asserts zero outbound sends when round is
  ROUND_DRAFT, confirms in-app notification rows still get written,
  and re-verifies the ACTIVE-round path still emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:12:41 +02:00
Matt
61dfc608cd fix(mentor): restore Add Project on mentoring rounds + gate mentor assignment
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
Three related bugs around the mentoring-round Projects tab:

1. Add Project to Round was unreachable on MENTORING rounds — the table swap
   in the prior commit lost the button. Export AddProjectDialog from
   project-states-table and render it inside MentoringProjectsTable with an
   "Add" button in the filter row and a CTA in the empty state.
2. The "Assign Projects" quick action on the round overview linked to the
   global pool with an opaque filter; on MENTORING rounds it now switches
   to the Projects tab where the new Add Project button + auto-fill +
   per-team picker all live. Non-mentoring rounds keep the old behavior.
3. mentor.assign and mentor.bulkAssign now refuse projects that aren't
   enrolled in any MENTORING round (any status). The single-assign throws
   BAD_REQUEST with a guidance message; the bulk path filters them out and
   reports ineligibleProjectCount in the result so the UI can warn the
   admin instead of silently skipping.

Tests: the multi-mentor-assignment suite now sets up a MENTORING round +
ProjectRoundState for each project it tests against, matching the new gate.
2026-05-26 15:20:01 +02:00
Matt
c4f7216bc1 feat(mentor): defer all assignment emails until round opens + per-project bulk UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
  outbound email entirely when the project's MENTORING round is still
  ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
  still fire), but notificationSentAt and teamIntroducedAt remain null so
  activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
  mentor-side email pass in addition to the existing team-side intro pass.
  Every (mentorId) bucket of pending assignments in this round gets exactly
  one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
  round is already ROUND_ACTIVE — mentors and teams stay in the loop in
  real time, but staging during draft is silent.

Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
  header select-all, and a primary-tinted action toolbar that appears when
  one or more candidates are selected. Submitting calls mentor.bulkAssign
  with the single projectId so the cartesian server path handles dedup,
  coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
Matt
cb2a864b7f feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.

Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.

Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
2026-05-26 14:25:41 +02:00
Matt
195fc787a9 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.
2026-05-26 14:04:32 +02:00
Matt
921019aaa4 fix(mentor): unbreak the mentor pipeline end-to-end
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
Adding the MENTOR role from /admin/members/[id] only updated React state — the
AlertDialog "Add role" confirmation never called the server, so prod ended up
with zero users in MENTOR roles[] and /admin/mentors showed "No mentors yet".
The dialog now awaits updateUser.mutateAsync({ roles }) before closing.

Other corrections in the same area:

- DialogContent uses flex flex-col with max-h-[90vh] overflow-y-auto so tall
  modals (e.g. Add Project to Round) scroll internally instead of overflowing
  past their own rounded background.
- getProjectsNeedingMentor now matches autoAssignBulkForRound exactly: both
  filter mentorAssignments by droppedAt: null and require
  finalistConfirmation: CONFIRMED, so the toolbar count never exceeds what
  auto-fill actually processes. The toolbar surfaces hasNoMentors /
  hasNoEligible / count / all-assigned as distinct states instead of one
  misleading "All eligible projects have a mentor" line.
- New per-team table (MentoringProjectsTable) replaces ProjectStatesTable on
  the Projects tab of MENTORING rounds. Lists every project with its active
  mentors (multi-mentor aware), filter pills, search, finalist-confirmation
  badge, and a per-row link to /admin/projects/[id]/mentor for assigning.
- Applicant team page now lists ALL active mentors (PR8 Task 7) instead of
  just mentorAssignments[0].
- Hard guard in src/lib/email.ts short-circuits sendEmail when NODE_ENV=test
  or VITEST=true so test runs can never emit real notifications again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:01:05 +02:00
16 changed files with 2261 additions and 56 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MentorAssignment" ADD COLUMN "teamIntroducedAt" TIMESTAMP(3);

View File

@@ -1281,9 +1281,16 @@ model MentorAssignment {
assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
// Per-assignment email idempotency: stamped once the MENTOR-side notification
// email has been sent (the "you've been assigned a project" email to the mentor).
notificationSentAt DateTime?
// Stamped once the TEAM has been introduced to this mentor (the "meet your
// mentor" email with mentor contact info). Fired by `activateRound` for
// MENTORING rounds and by mentor.assign when the project's MENTORING round
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
teamIntroducedAt DateTime?
// AI assignment metadata
aiConfidenceScore Float?
expertiseMatchScore Float?

View File

@@ -976,17 +976,39 @@ export default function MemberDetailPage() {
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onClick={async () => {
if (!pendingAdditionalRole) return
const { role: r, action } = pendingAdditionalRole
if (action === 'add') {
setAdditionalRoles((prev) =>
prev.includes(r) ? prev : [...prev, r]
const nextAdditional =
action === 'add'
? additionalRoles.includes(r)
? additionalRoles
: [...additionalRoles, r]
: additionalRoles.filter((x) => x !== r)
const nextAllRoles = [
role,
...nextAdditional.filter((x) => x !== role),
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
try {
await updateUser.mutateAsync({
id: userId,
roles: nextAllRoles,
})
setAdditionalRoles(nextAdditional)
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success(
action === 'add'
? `${r.replace(/_/g, ' ')} role added`
: `${r.replace(/_/g, ' ')} role removed`,
)
} else {
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update roles',
)
} finally {
setPendingAdditionalRole(null)
}
}}
>
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}

View File

@@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
@@ -74,6 +75,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
assignmentId: string
mentorName: string
} | null>(null)
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
new Set(),
)
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
@@ -111,6 +115,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setPendingMentorId(null)
},
onError: (err) => {
@@ -119,6 +124,30 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
},
})
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0) {
toast.info('No new assignments — every chosen mentor was already on this team.')
} else {
toast.success(
`Added ${result.totalAssigned} mentor${
result.totalAssigned === 1 ? '' : 's'
} to this team${
result.emailsSent > 0
? ` · ${result.emailsSent} email${result.emailsSent === 1 ? '' : 's'} sent`
: ' · emails will go out when the mentoring round opens'
}`,
)
}
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setSelectedCandidateIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
@@ -383,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
className="pl-9"
/>
</div>
{selectedCandidateIds.size > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selectedCandidateIds.size}</span>{' '}
<span className="text-muted-foreground">
mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(selectedCandidateIds),
projectIds: [projectId],
})
}
disabled={bulkAssignMutation.isPending}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Add {selectedCandidateIds.size} mentor
{selectedCandidateIds.size === 1 ? '' : 's'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedCandidateIds(new Set())}
>
Clear
</Button>
</div>
</div>
)}
{candidatesLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
@@ -400,6 +464,28 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filteredCandidates.length > 0 &&
filteredCandidates.every((c) =>
selectedCandidateIds.has(c.id),
)
}
onCheckedChange={(checked) => {
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) {
filteredCandidates.forEach((c) => next.add(c.id))
} else {
filteredCandidates.forEach((c) => next.delete(c.id))
}
return next
})
}}
aria-label="Select all visible mentors"
/>
</TableHead>
<TableHead>Mentor</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Country</TableHead>
@@ -410,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</TableHeader>
<TableBody>
{filteredCandidates.map((c) => (
<TableRow key={c.id}>
<TableRow
key={c.id}
data-state={
selectedCandidateIds.has(c.id) ? 'selected' : undefined
}
>
<TableCell>
<Checkbox
checked={selectedCandidateIds.has(c.id)}
onCheckedChange={(checked) =>
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) next.add(c.id)
else next.delete(c.id)
return next
})
}
aria-label={`Select ${c.name ?? c.email}`}
/>
</TableCell>
<TableCell>
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{c.email}</div>

View File

@@ -92,6 +92,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
@@ -168,6 +169,10 @@ function MentoringBulkAssignToolbar({
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
)
const count = pending?.count ?? 0
const eligibleTotal = pending?.eligibleTotal ?? 0
const mentorPoolSize = pending?.mentorPoolSize ?? 0
const hasNoMentors = mentorPoolSize === 0
const hasNoEligible = eligibleTotal === 0
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
onSuccess: (result) => {
@@ -190,23 +195,41 @@ function MentoringBulkAssignToolbar({
auto-fill is disabled. Assign each project manually.
</span>
</>
) : hasNoMentors ? (
<span className="text-muted-foreground">
No mentors in the pool yet {' '}
<Link
href="/admin/members?tab=mentors"
className="text-foreground underline-offset-2 hover:underline"
>
add mentors
</Link>{' '}
before auto-filling.
</span>
) : hasNoEligible ? (
<span className="text-muted-foreground">
No projects are eligible for mentorship in this round (
{eligibilityLabel}).
</span>
) : count > 0 ? (
<>
<span className="font-medium">{count}</span>{' '}
<span className="text-muted-foreground">
project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel})
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
needs a mentor ({eligibilityLabel})
</span>
</>
) : (
<span className="text-muted-foreground">
All eligible projects have a mentor.
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
already have a mentor.
</span>
)}
</div>
<Button
size="sm"
onClick={() => bulk.mutate({ roundId })}
disabled={isAdminSelected || count === 0 || bulk.isPending}
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
>
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Auto-fill remaining
@@ -1242,6 +1265,20 @@ export default function RoundDetailPage() {
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{isMentoring ? (
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Open the Projects tab to add or auto-fill teams in this round
</p>
</div>
</button>
) : (
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
@@ -1253,6 +1290,7 @@ export default function RoundDetailPage() {
</div>
</button>
</Link>
)}
<button
onClick={() => setActiveTab('projects')}
@@ -1570,8 +1608,17 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
{isMentoring && (
<>
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
<MentoringProjectsTable
roundId={roundId}
competitionId={competitionId}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
/>
</>
)}
{!isMentoring && (
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
@@ -1583,6 +1630,7 @@ export default function RoundDetailPage() {
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
)}
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}

View File

@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
)}
</div>
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
{project.mentorAssignments?.[0]?.mentor && (
{(() => {
type MentorAssignment = {
droppedAt: Date | string | null
mentor: { name: string | null; email: string } | null
}
const active = (
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
).filter((a) => !a.droppedAt && a.mentor)
if (active.length === 0) return null
return (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
<p className="text-sm font-medium mb-1">
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
</p>
</div>
<ul className="space-y-0.5">
{active.map((a, idx) => (
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
{a.mentor!.name ?? a.mentor!.email}
{a.mentor!.name && (
<span className="text-xs"> ({a.mentor!.email})</span>
)}
</li>
))}
</ul>
</div>
)
})()}
{/* Tags */}
{project.tags && project.tags.length > 0 && (

View File

@@ -0,0 +1,738 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Search,
UserPlus,
ArrowRight,
Sparkles,
Loader2,
Download,
X,
Plus,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
export function MentoringProjectsTable({
roundId,
competitionId,
competitionRounds,
currentSortOrder,
}: {
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
}) {
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils()
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: importCandidates } =
trpc.round.getMentoringImportCandidates.useQuery({ roundId })
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
{},
{ enabled: bulkOpen },
)
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
toast.info(
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
toast.warning(
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
`Created ${result.totalAssigned} assignment${
result.totalAssigned === 1 ? '' : 's'
} across ${result.touchedProjectCount} project${
result.touchedProjectCount === 1 ? '' : 's'
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
result.emailsSent > 0
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
: ''
}`,
{
description:
mentorCount > 1
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
: undefined,
},
)
}
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getMentorPool.invalidate()
utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate()
setSelected(new Set())
setChosenMentorIds(new Set())
setMentorSearch('')
setBulkOpen(false)
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => {
toast.success(
`Imported ${result.advancedCount} project${
result.advancedCount === 1 ? '' : 's'
} from ${result.targetRoundName ? '' : ''}${
importCandidates?.priorRound?.name ?? 'the prior round'
}`,
)
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const importBanner = importCandidates?.priorRound &&
importCandidates.pendingCount > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="text-amber-900">
<span className="font-medium">
{importCandidates.pendingCount} PASSED project
{importCandidates.pendingCount === 1 ? '' : 's'}
</span>{' '}
from{' '}
<span className="font-medium">
{importCandidates.priorRound.name}
</span>{' '}
{importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this
mentoring round yet.
</div>
<Button
size="sm"
onClick={() =>
advanceMutation.mutate({
roundId: importCandidates.priorRound!.id,
targetRoundId: roundId,
})
}
disabled={advanceMutation.isPending}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1.5 h-4 w-4" />
)}
Import {importCandidates.pendingCount}
</Button>
</div>
)
const filtered = useMemo(() => {
if (!data) return []
const q = search.trim().toLowerCase()
return data.projects.filter((p) => {
if (filter === 'unassigned' && p.mentors.length > 0) return false
if (filter === 'assigned' && p.mentors.length === 0) return false
if (filter === 'wants_only' && !p.wantsMentorship) return false
if (!q) return true
const hay = [
p.title,
p.teamName ?? '',
p.country ?? '',
...p.mentors.map((m) => m.name ?? m.email),
]
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [data, search, filter])
const totals = useMemo(() => {
if (!data)
return { total: 0, unassigned: 0, assigned: 0, wants: 0 }
return {
total: data.projects.length,
unassigned: data.projects.filter((p) => p.mentors.length === 0).length,
assigned: data.projects.filter((p) => p.mentors.length > 0).length,
wants: data.projects.filter((p) => p.wantsMentorship).length,
}
}, [data])
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)
}
if (!data || data.projects.length === 0) {
return (
<div className="space-y-3">
{importBanner}
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Project to Round
</Button>
</div>
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
No projects in this mentoring round yet. Click{' '}
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
above to populate it.
</div>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}
const Pill = ({
value,
label,
count,
}: {
value: Filter
label: string
count: number
}) => (
<button
type="button"
onClick={() => setFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
filter === value
? 'border-primary bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
{label}{' '}
<span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-3">
{importBanner}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-1.5">
<Pill value="all" label="All" count={totals.total} />
<Pill value="unassigned" label="No mentor" count={totals.unassigned} />
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
</div>
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-72">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects, teams, or mentors…"
className="pl-8"
/>
</div>
<Button
size="sm"
onClick={() => setAddProjectOpen(true)}
className="shrink-0"
>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</div>
{selected.size > 0 ? (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selected.size}</span>{' '}
<span className="text-muted-foreground">
project{selected.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => setBulkOpen(true)}>
<UserPlus className="mr-1.5 h-4 w-4" />
Assign mentor
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelected(new Set())}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
<span>
Tip: tick checkboxes to bulk-assign one mentor to multiple
projects in a single click (mentor gets one combined email).
</span>
{totals.unassigned > 0 && (
<button
type="button"
className="text-xs font-medium text-foreground hover:underline"
onClick={() => {
setFilter('unassigned')
setSelected(
new Set(
data.projects
.filter((p) => p.mentors.length === 0)
.map((p) => p.id),
),
)
}}
>
Select all {totals.unassigned} without a mentor
</button>
)}
</div>
)}
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filtered.length > 0 &&
filtered.every((p) => selected.has(p.id))
}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) {
filtered.forEach((p) => next.add(p.id))
} else {
filtered.forEach((p) => next.delete(p.id))
}
return next
})
}}
aria-label="Select all visible"
/>
</TableHead>
<TableHead>Project</TableHead>
<TableHead>Wants?</TableHead>
<TableHead>Mentors</TableHead>
<TableHead className="w-32 text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-sm text-muted-foreground"
>
No projects match the current filter.
</TableCell>
</TableRow>
) : (
filtered.map((p) => (
<TableRow
key={p.id}
data-state={selected.has(p.id) ? 'selected' : undefined}
>
<TableCell>
<Checkbox
checked={selected.has(p.id)}
onCheckedChange={(checked) =>
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(p.id)
else next.delete(p.id)
return next
})
}
aria-label={`Select ${p.title}`}
/>
</TableCell>
<TableCell>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-muted-foreground">
{p.teamName ?? '—'}
{p.country && (
<>
{' · '}
<CountryDisplay country={p.country} />
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{p.wantsMentorship ? (
<Badge variant="secondary" className="w-fit text-xs">
Requested
</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
{p.finalistConfirmationStatus !== 'CONFIRMED' && (
<span
className="text-[10px] uppercase tracking-wide text-amber-700"
title="Auto-fill skips projects whose team has not confirmed attendance."
>
{p.finalistConfirmationStatus
? p.finalistConfirmationStatus.toLowerCase()
: 'no confirmation'}
</span>
)}
</div>
</TableCell>
<TableCell>
{p.mentors.length === 0 ? (
<span className="text-xs italic text-muted-foreground">
Unassigned
</span>
) : (
<div className="flex flex-wrap gap-1">
{p.mentors.map((m) => (
<Badge
key={m.assignmentId}
variant="outline"
className="gap-1 text-xs"
title={m.email}
>
{(m.method === 'AI_AUTO' ||
m.method === 'AI_SUGGESTED') && (
<Sparkles className="h-3 w-3 text-amber-500" />
)}
{m.name ?? m.email}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/projects/${p.id}/mentor`}>
{p.mentors.length === 0 ? (
<>
<UserPlus className="mr-1 h-3.5 w-3.5" />
Assign
</>
) : (
<>
Open
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</>
)}
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog
open={bulkOpen}
onOpenChange={(next) => {
if (!next) {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'}
</DialogTitle>
<DialogDescription>
Tick any number of mentors. Each chosen mentor will be added to
every selected project they aren&apos;t already on. Each mentor
receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{(() => {
const allMentors = mentorPool?.mentors ?? []
const chosenMentors = allMentors.filter((m) =>
chosenMentorIds.has(m.id),
)
const upperBound = chosenMentorIds.size * selected.size
return (
<>
{chosenMentors.length > 0 && (
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
{chosenMentors.map((m) => (
<Badge
key={m.id}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
{m.name ?? m.email}
<button
type="button"
aria-label={`Remove ${m.name ?? m.email}`}
className="rounded-full p-0.5 hover:bg-foreground/10"
onClick={() =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
next.delete(m.id)
return next
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? allMentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: allMentors
if (allMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorIds.has(m.id)
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
className="mt-1"
checked={isChosen}
onCheckedChange={(checked) =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
if (checked) next.add(m.id)
else next.delete(m.id)
return next
})
}
aria-label={`Toggle ${m.name ?? m.email}`}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
{chosenMentorIds.size > 0 && (
<p className="text-xs text-muted-foreground">
Will create up to{' '}
<span className="font-medium tabular-nums text-foreground">
{upperBound}
</span>{' '}
assignment{upperBound === 1 ? '' : 's'} (
{chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
project{selected.size === 1 ? '' : 's'}). Pairs that
already exist are skipped.
</p>
)}
</>
)
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}}
>
Cancel
</Button>
<Button
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected),
})
}
disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Assign {chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
{selected.size === 1 ? '' : 's'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}

View File

@@ -785,7 +785,7 @@ function QuickAddDialog({
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
export function AddProjectDialog({
open,
onOpenChange,
roundId,

View File

@@ -34,7 +34,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 flex max-h-[90vh] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className
)}
{...props}

View File

@@ -9,6 +9,12 @@ import { prisma } from '@/lib/prisma'
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
// Hard guard: never send real email from the test runner. This is a belt-and-
// braces check on top of the vitest-level mock in tests/setup.ts. Vitest sets
// NODE_ENV='test' and exposes VITEST=true automatically.
if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') {
return
}
const { transporter, from } = await getTransporter()
const to = DEV_EMAIL_OVERRIDE || opts.to
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
@@ -2826,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).

View File

@@ -8,8 +8,10 @@ import {
type PrismaClient,
} from '@prisma/client'
import {
sendMentorBulkAssignmentEmail,
sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
import {
getAIMentorSuggestions,
@@ -46,6 +48,104 @@ import {
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* True if the project is enrolled in a MENTORING round that is still
* ROUND_DRAFT. Used to defer mentor- and team-side emails until the round
* opens, so admins can stage assignments without sending notifications.
* If the project isn't in a MENTORING round at all, returns false
* (i.e. send emails normally — there's no round-open event to wait for).
*/
async function shouldDeferEmailsForProject(
prisma: PrismaClient,
projectId: string,
): Promise<boolean> {
const draftRoundEnrollment = await prisma.projectRoundState.findFirst({
where: {
projectId,
round: { roundType: 'MENTORING', status: 'ROUND_DRAFT' },
},
select: { id: true },
})
return draftRoundEnrollment !== null
}
/**
* Introduce the project team to ALL active mentors via email IF the project's
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
* whose assignment row has `teamIntroducedAt: null`. If the round is not yet
* active, this is a no-op — the activation step will fire the email instead.
* Never throws.
*/
async function introduceTeamToMentorsIfRoundOpen(
prisma: PrismaClient,
projectId: string,
): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
projectRoundStates: {
where: {
round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
},
select: { id: true },
take: 1,
},
mentorAssignments: {
where: { droppedAt: null, teamIntroducedAt: null },
select: {
id: true,
mentor: { select: { name: true, email: true } },
},
},
teamMembers: {
select: { user: { select: { name: true, email: true } } },
},
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
})
if (!project) return
if (project.projectRoundStates.length === 0) return // round not active yet
const mentors = project.mentorAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
if (mentors.length === 0) return
const recipients = new Map<string, { name: string | null }>()
for (const tm of project.teamMembers) {
if (tm.user?.email) {
recipients.set(tm.user.email, { name: tm.user.name })
}
}
if (
project.submittedByEmail &&
!recipients.has(project.submittedByEmail)
) {
recipients.set(project.submittedByEmail, {
name: project.submittedBy?.name ?? null,
})
}
for (const [email, { name }] of recipients) {
await sendTeamMentorIntroductionEmail(
email,
name,
project.title,
project.id,
mentors,
)
}
await prisma.mentorAssignment.updateMany({
where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
data: { teamIntroducedAt: new Date() },
})
} catch (e) {
console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
}
}
/**
* Throws TRPCError if the given user is neither the assigned mentor
* nor a team member of the project linked to the assignment.
@@ -270,6 +370,25 @@ export const mentorRouter = router({
where: { id: input.projectId },
})
// Gate: the project MUST be in a MENTORING round (any status, including
// DRAFT, ACTIVE, or CLOSED). We do not allow mentor assignment for
// projects that aren't part of a mentoring round — those should be
// added to a mentoring round first.
const inMentoringRound = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING' },
},
select: { id: true },
})
if (!inMentoringRound) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'This project is not in a mentoring round. Add it to a mentoring round first, then assign mentors.',
})
}
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
@@ -346,6 +465,15 @@ export const mentorRouter = router({
include: { user: { select: { name: true, email: true } } },
})
// Defer emails (mentor-side and team-side) while the project's MENTORING
// round is still ROUND_DRAFT — `activateRound` coalesces and fires them
// when the admin opens the round. In-app notifications still fire so the
// staged assignment is visible immediately.
const deferThisEmail = await shouldDeferEmailsForProject(
ctx.prisma,
input.projectId,
)
// Notify mentor of new mentee
await createNotification({
userId: input.mentorId,
@@ -360,6 +488,7 @@ export const mentorRouter = router({
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
skipEmail: deferThisEmail,
})
// Notify project team of mentor assignment
@@ -374,13 +503,14 @@ export const mentorRouter = router({
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
skipEmail: deferThisEmail,
})
// Send per-team email notification once per assignment row. Idempotency
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
// it null. If the same mentor is later dropped and re-assigned (new row,
// fresh id), a new email is sent — intentional.
if (assignment.notificationSentAt == null && assignment.mentor.email) {
if (
!deferThisEmail &&
assignment.notificationSentAt == null &&
assignment.mentor.email
) {
await sendMentorTeamAssignmentEmail(
assignment.mentor.email,
assignment.mentor.name,
@@ -414,6 +544,10 @@ export const mentorRouter = router({
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
// If the project's MENTORING round is already open, introduce the team
// to their mentor(s) by email now. Otherwise the activation hook fires it.
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
return assignment
}),
@@ -531,6 +665,13 @@ export const mentorRouter = router({
include: { user: { select: { name: true, email: true } } },
})
// Defer email notifications if the project's MENTORING round is still
// in draft — activateRound will fire coalesced emails at round-open.
const deferThisEmail = await shouldDeferEmailsForProject(
ctx.prisma,
input.projectId,
)
// Notify mentor of new mentee
await createNotification({
userId: mentorId,
@@ -545,6 +686,7 @@ export const mentorRouter = router({
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
skipEmail: deferThisEmail,
})
// Notify project team of mentor assignment
@@ -559,11 +701,258 @@ export const mentorRouter = router({
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
skipEmail: deferThisEmail,
})
return assignment
}),
/**
* Bulk-assign MANY mentors to MANY projects (cartesian product) in one
* call. Skips (mentor, project) pairs where the mentor is already an
* active mentor on that project. Each affected mentor receives ONE
* coalesced email listing only their newly-assigned projects. Each team
* whose project's MENTORING round is already open receives ONE intro
* email listing all their active mentors (including any pre-existing).
*/
bulkAssign: adminProcedure
.input(
z.object({
mentorIds: z.array(z.string()).min(1),
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: input.mentorIds } },
select: { id: true, name: true, email: true, roles: true },
})
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
if (validMentors.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'None of the selected users have the MENTOR role',
})
}
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.projectIds },
// Gate: only projects that are in some MENTORING round (any status)
projectRoundStates: {
some: { round: { roundType: 'MENTORING' } },
},
},
select: {
id: true,
title: true,
mentorAssignments: {
where: {
mentorId: { in: validMentors.map((m) => m.id) },
droppedAt: null,
},
select: { mentorId: true },
},
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'None of the selected projects are in a mentoring round. Add them to a mentoring round first.',
})
}
const ineligibleCount = input.projectIds.length - projects.length
// Track per-mentor (for emails) and per-project (for team intros) state.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
newProjects: { id: string; title: string }[]
skippedProjects: { id: string; title: string }[]
}
>()
for (const m of validMentors) {
perMentor.set(m.id, {
email: m.email ?? null,
name: m.name ?? null,
assignmentIds: [],
newProjects: [],
skippedProjects: [],
})
}
const touchedProjectIds = new Set<string>()
let totalAssigned = 0
let totalSkipped = 0
// Pre-compute which projects must defer outbound email because their
// MENTORING round is still in draft. The in-app notification still
// fires; only the parallel notification-system email is suppressed,
// exactly like the coalesced mentor email path below. `activateRound`
// sends one combined email per mentor + one team intro per project
// when the admin opens the round.
const draftProjectIds = new Set<string>()
for (const project of projects) {
if (await shouldDeferEmailsForProject(ctx.prisma, project.id)) {
draftProjectIds.add(project.id)
}
}
for (const project of projects) {
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
const deferForThis = draftProjectIds.has(project.id)
for (const mentor of validMentors) {
const bucket = perMentor.get(mentor.id)!
if (alreadyOn.has(mentor.id)) {
bucket.skippedProjects.push({ id: project.id, title: project.title })
totalSkipped++
continue
}
const created = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: ctx.user.id,
},
})
bucket.assignmentIds.push(created.id)
bucket.newProjects.push({ id: project.id, title: project.title })
touchedProjectIds.add(project.id)
totalAssigned++
await createNotification({
userId: mentor.id,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title },
skipEmail: deferForThis,
})
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title, mentorName: mentor.name },
skipEmail: deferForThis,
})
}
// Best-effort: mark project IN_PROGRESS in the active MENTORING round
if (touchedProjectIds.has(project.id)) {
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: project.id,
round: {
roundType: 'MENTORING',
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
},
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(
project.id,
mentoringPrs.roundId,
ctx.user.id,
ctx.prisma,
)
}
} catch (e) {
console.error(
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
e,
)
}
}
}
// `draftProjectIds` was computed before the assignment loop above.
// One email per mentor, listing only their NEW projects whose mentoring
// round is NOT in draft. If every new project is deferred, no email.
for (const bucket of perMentor.values()) {
if (!bucket.email) continue
const sendableProjects = bucket.newProjects.filter(
(p) => !draftProjectIds.has(p.id),
)
if (sendableProjects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
sendableProjects,
)
// Only stamp notificationSentAt for the assignments that correspond
// to projects we actually emailed about. Draft-deferred ones stay
// unstamped so activateRound picks them up.
const sendableProjectIds = new Set(sendableProjects.map((p) => p.id))
await ctx.prisma.mentorAssignment.updateMany({
where: {
id: { in: bucket.assignmentIds },
projectId: { in: Array.from(sendableProjectIds) },
},
data: { notificationSentAt: new Date() },
})
}
// Team-intro email per touched project (only fires if the round is
// already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
// projects are naturally deferred to activateRound's intro pass).
for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'BulkAssign',
entityId: 'multi',
detailsJson: {
mentorIds: validMentors.map((m) => m.id),
projectIds: input.projectIds,
totalAssigned,
totalSkipped,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
totalAssigned,
totalSkipped,
ineligibleProjectCount: ineligibleCount,
touchedProjectCount: touchedProjectIds.size,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
mentorName: b.name,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
emailsSent: Array.from(perMentor.values()).filter(
(b) => b.newProjects.length > 0 && b.email,
).length,
}
}),
/**
* Remove mentor assignment.
*
@@ -714,6 +1103,12 @@ export const mentorRouter = router({
include: { user: { select: { name: true, email: true } } },
})
// Defer emails when the project's MENTORING round is still in draft.
const deferThisEmail = await shouldDeferEmailsForProject(
ctx.prisma,
project.id,
)
// Notify mentor
await createNotification({
userId: mentorId,
@@ -728,6 +1123,7 @@ export const mentorRouter = router({
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
skipEmail: deferThisEmail,
})
// Notify project team
@@ -742,6 +1138,7 @@ export const mentorRouter = router({
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
skipEmail: deferThisEmail,
})
assigned++
@@ -795,7 +1192,7 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, roundType: true, configJson: true },
select: { id: true, roundType: true, configJson: true, status: true },
})
if (round.roundType !== 'MENTORING') {
throw new TRPCError({
@@ -818,7 +1215,7 @@ export const mentorRouter = router({
where: {
roundId: input.roundId,
project: {
mentorAssignments: { none: {} },
mentorAssignments: { none: { droppedAt: null } },
// Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all.
@@ -842,6 +1239,23 @@ export const mentorRouter = router({
let assigned = 0
let unassignable = 0
// Defer outbound emails when the round is still in draft — same gate
// used by mentor.assign/bulkAssign. In-app notifications still fire so
// the staged assignment is visible to the mentor + team immediately.
const deferEmailsForRound = round.status === 'ROUND_DRAFT'
// Coalesce per-mentor so we send ONE email per mentor at the end of the
// batch, even when the algorithm assigns the same mentor to several teams.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
}
>()
for (const { project } of projectStates) {
try {
let mentorId: string | null = null
@@ -883,7 +1297,7 @@ export const mentorRouter = router({
aiReasoning,
},
include: {
mentor: { select: { id: true, name: true } },
mentor: { select: { id: true, name: true, email: true } },
project: { select: { title: true } },
},
})
@@ -906,6 +1320,7 @@ export const mentorRouter = router({
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
skipEmail: deferEmailsForRound,
})
await notifyProjectTeam(project.id, {
@@ -919,8 +1334,20 @@ export const mentorRouter = router({
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
skipEmail: deferEmailsForRound,
})
// Accumulate for the coalesced email
const bucket = perMentor.get(mentorId) ?? {
email: assignment.mentor.email ?? null,
name: assignment.mentor.name ?? null,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(assignment.id)
bucket.projects.push({ id: project.id, title: assignment.project.title })
perMentor.set(mentorId, bucket)
assigned++
} catch (err) {
console.error(
@@ -932,6 +1359,49 @@ export const mentorRouter = router({
}
}
// Defer all emails when the round is still ROUND_DRAFT — activateRound
// will coalesce and send them when the admin opens the round. Stamp
// notificationSentAt only for assignments we actually email about, so
// activateRound's `notificationSentAt IS NULL` filter catches the rest.
const roundStatus = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { status: true },
})
const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'
if (isRoundLive) {
for (const bucket of perMentor.values()) {
if (!bucket.email || bucket.projects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
try {
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error(
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
e,
)
}
}
const introducedProjects = new Set<string>()
for (const bucket of perMentor.values()) {
for (const p of bucket.projects) {
if (introducedProjects.has(p.id)) continue
introducedProjects.add(p.id)
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
}
}
}
// If the round is still ROUND_DRAFT, no emails fire here — the assignments
// remain unstamped and activateRound will batch-send when the round opens.
const skipped = await ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,

View File

@@ -227,21 +227,169 @@ export const roundRouter = router({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') return { count: 0 }
if (round.roundType !== 'MENTORING') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
if (eligibility === 'admin_selected') return { count: 0 }
if (eligibility === 'admin_selected') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const count = await ctx.prisma.projectRoundState.count({
const eligibilityWhere =
eligibility === 'requested_only' ? { wantsMentorship: true } : {}
// Mirror autoAssignBulkForRound's filter exactly so the toolbar count
// matches what the auto-fill button will actually process.
const autoFillWhere = {
mentorAssignments: { none: { droppedAt: null } },
finalistConfirmation: { status: 'CONFIRMED' as const },
...eligibilityWhere,
}
const [count, eligibleTotal, mentorPoolSize] = await Promise.all([
ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, project: autoFillWhere },
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: {
mentorAssignments: { none: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
finalistConfirmation: { status: 'CONFIRMED' as const },
...eligibilityWhere,
},
},
}),
ctx.prisma.user.count({
where: { roles: { has: 'MENTOR' }, status: { not: 'SUSPENDED' } },
}),
])
return { count, eligibleTotal, mentorPoolSize }
}),
/**
* For a MENTORING round, find the immediately-prior round in the same
* competition and report how many of its PASSED projects are not yet
* present in this round. Drives the "Import from prior round" CTA so
* admins don't have to manually pick projects via the From-Round modal.
*/
getMentoringImportCandidates: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, competitionId: true, sortOrder: true },
})
return { count }
if (round.roundType !== 'MENTORING') {
return { priorRound: null, pendingCount: 0 }
}
const prior = await ctx.prisma.round.findFirst({
where: {
competitionId: round.competitionId,
sortOrder: { lt: round.sortOrder },
},
orderBy: { sortOrder: 'desc' },
select: { id: true, name: true, status: true },
})
if (!prior) return { priorRound: null, pendingCount: 0 }
if (prior.status !== 'ROUND_ACTIVE' && prior.status !== 'ROUND_CLOSED') {
return {
priorRound: { id: prior.id, name: prior.name, status: prior.status },
pendingCount: 0,
}
}
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
})
const existingIds = new Set(existingInTarget.map((s) => s.projectId))
const passedInPrior = await ctx.prisma.projectRoundState.findMany({
where: { roundId: prior.id, state: 'PASSED' },
select: { projectId: true },
})
const pendingCount = passedInPrior.filter(
(s) => !existingIds.has(s.projectId),
).length
return {
priorRound: { id: prior.id, name: prior.name, status: prior.status },
pendingCount,
}
}),
/**
* List projects in a MENTORING round with their (multi-)mentor assignments.
* Drives the per-team assignment table on the round Projects tab so admins
* can see who is assigned to whom and add/swap mentors per project.
*/
listMentoringProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') return { projects: [] }
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
const states = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: {
state: true,
project: {
select: {
id: true,
title: true,
teamName: true,
country: true,
wantsMentorship: true,
competitionCategory: true,
finalistConfirmation: { select: { status: true } },
mentorAssignments: {
where: { droppedAt: null },
select: {
id: true,
method: true,
assignedAt: true,
mentor: { select: { id: true, name: true, email: true } },
},
orderBy: { assignedAt: 'asc' },
},
},
},
},
orderBy: [{ project: { title: 'asc' } }],
})
return {
eligibility,
projects: states.map((s) => {
const isEligible =
eligibility === 'all_in_round' ||
eligibility === 'admin_selected' ||
s.project.wantsMentorship
return {
id: s.project.id,
title: s.project.title,
teamName: s.project.teamName,
country: s.project.country,
competitionCategory: s.project.competitionCategory,
wantsMentorship: s.project.wantsMentorship,
finalistConfirmationStatus:
s.project.finalistConfirmation?.status ?? null,
isEligible,
state: s.state,
mentors: s.project.mentorAssignments.map((a) => ({
assignmentId: a.id,
method: a.method,
assignedAt: a.assignedAt,
id: a.mentor.id,
name: a.mentor.name,
email: a.mentor.email,
})),
}
}),
}
}),
/**

View File

@@ -169,6 +169,14 @@ interface CreateNotificationParams {
metadata?: Record<string, unknown>
groupKey?: string
expiresAt?: Date
/**
* When true, the in-app notification still fires but the parallel email
* send (via NotificationEmailSetting) is suppressed. Callers use this when
* the email belongs to a coalesced/deferred flow that will fire later
* (e.g. mentor assignments staged while a MENTORING round is ROUND_DRAFT —
* the round-open hook sends a single combined email instead).
*/
skipEmail?: boolean
}
/**
@@ -189,6 +197,7 @@ export async function createNotification(
metadata,
groupKey,
expiresAt,
skipEmail,
} = params
// Determine icon and priority if not provided
@@ -241,8 +250,11 @@ export async function createNotification(
},
})
// Check if we should also send an email
// Check if we should also send an email (suppressed when the caller is
// deferring the email to a coalesced flow).
if (!skipEmail) {
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
}
}
/**
@@ -258,6 +270,8 @@ export async function createBulkNotifications(params: {
icon?: string
priority?: NotificationPriority
metadata?: Record<string, unknown>
/** See {@link CreateNotificationParams.skipEmail}. */
skipEmail?: boolean
}): Promise<void> {
const {
userIds,
@@ -269,6 +283,7 @@ export async function createBulkNotifications(params: {
icon,
priority,
metadata,
skipEmail,
} = params
const finalIcon = icon || NotificationIcons[type] || 'Bell'
@@ -289,6 +304,8 @@ export async function createBulkNotifications(params: {
})),
})
if (skipEmail) return
// Check email settings once, then send emails only if enabled
const emailSetting = await prisma.notificationEmailSetting.findUnique({
where: { notificationType: type },

View File

@@ -16,6 +16,10 @@ import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -211,6 +215,150 @@ export async function activateRound(
} catch (mentoringError) {
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
}
// Mentor-side coalesced emails on round open. Picks up every assignment
// for projects in this round whose notificationSentAt is null (i.e.
// assignments made while the round was still in draft), groups by
// mentor, and sends a single combined email per mentor listing all
// their projects in this round.
try {
const pendingAssignments = await prisma.mentorAssignment.findMany({
where: {
droppedAt: null,
notificationSentAt: null,
project: { projectRoundStates: { some: { roundId } } },
},
select: {
id: true,
mentorId: true,
mentor: { select: { name: true, email: true } },
project: { select: { id: true, title: true } },
},
})
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
}
>()
for (const a of pendingAssignments) {
if (!a.mentor?.email) continue
const bucket = perMentor.get(a.mentorId) ?? {
email: a.mentor.email,
name: a.mentor.name,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(a.id)
bucket.projects.push({ id: a.project.id, title: a.project.title })
perMentor.set(a.mentorId, bucket)
}
for (const bucket of perMentor.values()) {
if (bucket.projects.length === 0 || !bucket.email) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
await prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
}
if (perMentor.size > 0) {
console.log(
`[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`,
)
}
} catch (mentorEmailError) {
console.error(
'[RoundEngine] Mentor-side coalesced notification failed (non-fatal):',
mentorEmailError,
)
}
// Introduce teams to their mentors via email when the round opens.
// Idempotent via MentorAssignment.teamIntroducedAt — separate from the
// mentor-side notificationSentAt so the team email fires even when the
// mentor was assigned (and notified) before the round opened.
try {
const projectsToIntroduce = await prisma.project.findMany({
where: {
projectRoundStates: { some: { roundId } },
mentorAssignments: {
some: { droppedAt: null, teamIntroducedAt: null },
},
},
select: {
id: true,
title: true,
mentorAssignments: {
where: { droppedAt: null },
select: {
id: true,
teamIntroducedAt: true,
mentor: { select: { name: true, email: true } },
},
},
teamMembers: {
select: { user: { select: { name: true, email: true } } },
},
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
})
for (const p of projectsToIntroduce) {
const mentors = p.mentorAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({
name: a.mentor.name,
email: a.mentor.email,
}))
if (mentors.length === 0) continue
// Build a unique recipient set: team-member users with emails,
// plus the original submitter (in case they're not on the team yet).
const recipients = new Map<string, { name: string | null }>()
for (const tm of p.teamMembers) {
if (tm.user?.email) {
recipients.set(tm.user.email, { name: tm.user.name })
}
}
if (
p.submittedByEmail &&
!recipients.has(p.submittedByEmail)
) {
recipients.set(p.submittedByEmail, {
name: p.submittedBy?.name ?? null,
})
}
for (const [email, { name }] of recipients) {
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors)
}
// Stamp every mentor-assignment row so re-activation doesn't re-send.
const idsToStamp = p.mentorAssignments
.filter((a) => a.teamIntroducedAt == null)
.map((a) => a.id)
if (idsToStamp.length > 0) {
await prisma.mentorAssignment.updateMany({
where: { id: { in: idsToStamp } },
data: { teamIntroducedAt: new Date() },
})
}
}
if (projectsToIntroduce.length > 0) {
console.log(
`[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`,
)
}
} catch (introError) {
console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError)
}
}
return {

View File

@@ -0,0 +1,228 @@
/**
* Regression: mentor-assignment emails must be deferred while the
* project's MENTORING round is still ROUND_DRAFT. The earlier fix only
* deferred the explicit `sendMentorBulkAssignmentEmail` path; the parallel
* in-app-notification → email path (MENTEE_ASSIGNED, MENTOR_ASSIGNED) kept
* firing immediately, causing duplicate sends both at assign-time AND
* again when activateRound coalesced the same assignments. Verified
* against prod incident 2026-05-26 (Camille Lopez received 9 emails).
*
* These tests assert that:
* - in DRAFT: in-app notifications still create rows, but the styled
* notification email is NOT sent;
* - in ACTIVE: the styled notification email IS sent (legacy behaviour
* preserved when the round is open).
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendStyledNotificationEmail: vi.fn(async () => undefined),
sendMentorTeamAssignmentEmail: vi.fn(async () => undefined),
sendMentorBulkAssignmentEmail: vi.fn(async () => undefined),
sendTeamMentorIntroductionEmail: vi.fn(async () => undefined),
}
})
const email = await import('@/lib/email')
const sendStyledMock = email.sendStyledNotificationEmail as ReturnType<typeof vi.fn>
const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType<typeof vi.fn>
const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType<typeof vi.fn>
async function makeMentor(): Promise<{ id: string; email: string }> {
const id = uid('mentor')
const u = await prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Mentor ${id}`,
role: 'MENTOR' as UserRole,
roles: ['MENTOR'] as UserRole[],
status: 'ACTIVE',
// Email path requires the user to opt into emails. Default for new test
// users is EMAIL so styled-email sends fire when the gate is open.
notificationPreference: 'EMAIL',
},
})
return { id: u.id, email: u.email }
}
async function makeTeamMember(projectId: string): Promise<string> {
const id = uid('teamuser')
const u = await prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Team ${id}`,
role: 'APPLICANT' as UserRole,
roles: ['APPLICANT'] as UserRole[],
status: 'ACTIVE',
notificationPreference: 'EMAIL',
},
})
await prisma.teamMember.create({
data: { projectId, userId: u.id, role: 'MEMBER' },
})
return u.id
}
async function attachToMentoringRound(
programId: string,
projectId: string,
status: 'ROUND_DRAFT' | 'ROUND_ACTIVE',
): Promise<string> {
const slug = uid()
const competition = await prisma.competition.create({
data: {
name: `Comp ${slug}`,
slug: `comp-${slug}`,
programId,
status: 'ACTIVE',
},
})
const round = await prisma.round.create({
data: {
name: `Mentoring ${slug}`,
slug: `mentoring-${slug}`,
roundType: 'MENTORING',
sortOrder: 1,
status,
competitionId: competition.id,
},
})
await prisma.projectRoundState.create({
data: { roundId: round.id, projectId },
})
return round.id
}
describe('mentor-assignment email deferral (regression for 2026-05-26 duplicate-email incident)', () => {
const programIds: string[] = []
const userIds: string[] = []
beforeEach(() => {
sendStyledMock.mockClear()
sendMentorBulkMock.mockClear()
sendTeamIntroMock.mockClear()
})
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('mentor.assign in DRAFT round creates in-app notif rows but sends ZERO emails', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-draft-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Draft Project' })
await attachToMentoringRound(program.id, project.id, 'ROUND_DRAFT')
const mentor = await makeMentor()
userIds.push(mentor.id)
const teamUser = await makeTeamMember(project.id)
userIds.push(teamUser)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
expect(sendStyledMock).not.toHaveBeenCalled()
expect(sendMentorBulkMock).not.toHaveBeenCalled()
expect(sendTeamIntroMock).not.toHaveBeenCalled()
// In-app notification rows still fire so admin + mentor see staged state.
const mentorNotifs = await prisma.inAppNotification.findMany({
where: { userId: mentor.id, type: 'MENTEE_ASSIGNED' },
})
expect(mentorNotifs.length).toBe(1)
const teamNotifs = await prisma.inAppNotification.findMany({
where: { userId: teamUser, type: 'MENTOR_ASSIGNED' },
})
expect(teamNotifs.length).toBe(1)
})
it('mentor.assign in ACTIVE round still sends the per-assignment emails (legacy behaviour preserved)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-active-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Active Project' })
await attachToMentoringRound(program.id, project.id, 'ROUND_ACTIVE')
const mentor = await makeMentor()
userIds.push(mentor.id)
const teamUser = await makeTeamMember(project.id)
userIds.push(teamUser)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
// Either styled notif email OR the explicit team-intro email is allowed
// to fire here — point is: at least one outbound email happens when the
// round is open. The DRAFT test above is the one that must stay at zero.
const sentCount =
sendStyledMock.mock.calls.length + sendTeamIntroMock.mock.calls.length
expect(sentCount).toBeGreaterThan(0)
})
it('mentor.bulkAssign in DRAFT round sends ZERO emails across multiple projects', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-bulk-${uid()}` })
programIds.push(program.id)
const p1 = await createTestProject(program.id, { title: 'BulkDraft 1' })
const p2 = await createTestProject(program.id, { title: 'BulkDraft 2' })
const p3 = await createTestProject(program.id, { title: 'BulkDraft 3' })
await attachToMentoringRound(program.id, p1.id, 'ROUND_DRAFT')
await attachToMentoringRound(program.id, p2.id, 'ROUND_DRAFT')
await attachToMentoringRound(program.id, p3.id, 'ROUND_DRAFT')
const mentor = await makeMentor()
userIds.push(mentor.id)
userIds.push(await makeTeamMember(p1.id))
userIds.push(await makeTeamMember(p2.id))
userIds.push(await makeTeamMember(p3.id))
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.bulkAssign({
mentorIds: [mentor.id],
projectIds: [p1.id, p2.id, p3.id],
})
expect(sendStyledMock).not.toHaveBeenCalled()
expect(sendMentorBulkMock).not.toHaveBeenCalled()
expect(sendTeamIntroMock).not.toHaveBeenCalled()
})
})

View File

@@ -42,6 +42,37 @@ async function createUserWithRoles(
})
}
/**
* mentor.assign and mentor.bulkAssign now require the project to be enrolled
* in some MENTORING round. This helper sets up the minimum: one competition
* + one MENTORING round + one ProjectRoundState linking the project.
*/
async function attachToMentoringRound(programId: string, projectId: string) {
const compSlug = `comp-${uid()}`
const competition = await prisma.competition.create({
data: {
name: `Comp ${compSlug}`,
slug: compSlug,
programId,
status: 'ACTIVE',
},
})
const round = await prisma.round.create({
data: {
name: `Mentoring ${uid()}`,
slug: `mentoring-${uid()}`,
roundType: 'MENTORING',
sortOrder: 1,
status: 'ROUND_ACTIVE',
competitionId: competition.id,
},
})
await prisma.projectRoundState.create({
data: { roundId: round.id, projectId },
})
return { competitionId: competition.id, roundId: round.id }
}
describe('mentor.assign — stacking + per-team email idempotency', () => {
const programIds: string[] = []
const userIds: string[] = []
@@ -62,6 +93,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Stacking Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
@@ -93,6 +125,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Dup Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
@@ -114,7 +147,9 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-email-${uid()}` })
programIds.push(program.id)
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
await attachToMentoringRound(program.id, project1.id)
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
await attachToMentoringRound(program.id, project2.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
@@ -144,6 +179,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
userIds.push(m1.id, m2.id)
@@ -169,6 +205,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)