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.
This commit is contained in:
Matt
2026-05-26 14:48:38 +02:00
parent cb2a864b7f
commit c4f7216bc1
3 changed files with 265 additions and 40 deletions

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>