feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "teamIntroducedAt" TIMESTAMP(3);
|
||||||
@@ -1281,9 +1281,16 @@ model MentorAssignment {
|
|||||||
assignedAt DateTime @default(now())
|
assignedAt DateTime @default(now())
|
||||||
assignedBy String? // Admin who assigned
|
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?
|
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
|
// AI assignment metadata
|
||||||
aiConfidenceScore Float?
|
aiConfidenceScore Float?
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -15,7 +16,24 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Search, UserPlus, ArrowRight, Sparkles } from 'lucide-react'
|
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,
|
||||||
|
} from 'lucide-react'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
||||||
@@ -23,12 +41,106 @@ type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
|||||||
export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
|
const [chosenMentorId, setChosenMentorId] = useState<string>('')
|
||||||
|
const [mentorSearch, setMentorSearch] = useState('')
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
|
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 30_000 },
|
{ 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.assignedCount === 0 && result.skippedCount > 0) {
|
||||||
|
toast.info(
|
||||||
|
`No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`Assigned mentor to ${result.assignedCount} project${
|
||||||
|
result.assignedCount === 1 ? '' : 's'
|
||||||
|
}${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${
|
||||||
|
result.emailSent ? ' · email sent' : ''
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
setChosenMentorId('')
|
||||||
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
@@ -72,8 +184,20 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
|
|
||||||
if (!data || data.projects.length === 0) {
|
if (!data || data.projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
|
<div className="space-y-3">
|
||||||
No projects in this mentoring round yet.
|
{importBanner}
|
||||||
|
<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.
|
||||||
|
{!importBanner && (
|
||||||
|
<>
|
||||||
|
{' '}Use{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
Add Project to Round
|
||||||
|
</span>{' '}
|
||||||
|
to populate it.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -103,6 +227,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Pill value="all" label="All" count={totals.total} />
|
<Pill value="all" label="All" count={totals.total} />
|
||||||
@@ -121,10 +246,80 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="overflow-hidden rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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>Project</TableHead>
|
||||||
<TableHead>Wants?</TableHead>
|
<TableHead>Wants?</TableHead>
|
||||||
<TableHead>Mentors</TableHead>
|
<TableHead>Mentors</TableHead>
|
||||||
@@ -135,7 +330,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={4}
|
colSpan={5}
|
||||||
className="py-8 text-center text-sm text-muted-foreground"
|
className="py-8 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No projects match the current filter.
|
No projects match the current filter.
|
||||||
@@ -143,7 +338,24 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((p) => (
|
filtered.map((p) => (
|
||||||
<TableRow key={p.id}>
|
<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>
|
<TableCell>
|
||||||
<div className="font-medium">{p.title}</div>
|
<div className="font-medium">{p.title}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
@@ -224,6 +436,162 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={bulkOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) {
|
||||||
|
setBulkOpen(false)
|
||||||
|
setChosenMentorId('')
|
||||||
|
setMentorSearch('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Assign mentor to {selected.size} project
|
||||||
|
{selected.size === 1 ? '' : 's'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose one mentor — they'll receive a single email listing every
|
||||||
|
new assignment. Projects where they're already an active mentor
|
||||||
|
will be skipped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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 mentors = mentorPool?.mentors ?? []
|
||||||
|
const q = mentorSearch.trim().toLowerCase()
|
||||||
|
const filteredMentors = q
|
||||||
|
? mentors.filter((m) =>
|
||||||
|
[
|
||||||
|
m.name ?? '',
|
||||||
|
m.email,
|
||||||
|
m.country ?? '',
|
||||||
|
...(m.expertiseTags ?? []),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
|
)
|
||||||
|
: mentors
|
||||||
|
if (mentors.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 “{mentorSearch}”.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return filteredMentors.map((m) => {
|
||||||
|
const isChosen = chosenMentorId === 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="bulk-mentor"
|
||||||
|
className="mt-1"
|
||||||
|
checked={isChosen}
|
||||||
|
onChange={() => setChosenMentorId(m.id)}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setBulkOpen(false)
|
||||||
|
setChosenMentorId('')
|
||||||
|
setMentorSearch('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
bulkAssignMutation.mutate({
|
||||||
|
mentorId: chosenMentorId,
|
||||||
|
projectIds: Array.from(selected),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!chosenMentorId || bulkAssignMutation.isPending}
|
||||||
|
>
|
||||||
|
{bulkAssignMutation.isPending && (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Assign to {selected.size} project
|
||||||
|
{selected.size === 1 ? '' : 's'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
211
src/lib/email.ts
211
src/lib/email.ts
@@ -2832,6 +2832,217 @@ export async function sendMentorTeamAssignmentEmail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeamMentorIntroductionTemplate(
|
||||||
|
recipientName: string | null,
|
||||||
|
projectTitle: string,
|
||||||
|
mentors: { name: string | null; email: string }[],
|
||||||
|
workspaceUrl: string,
|
||||||
|
): EmailTemplate {
|
||||||
|
const count = mentors.length
|
||||||
|
const subject =
|
||||||
|
count === 1
|
||||||
|
? `Your mentor for "${projectTitle}" on MOPC`
|
||||||
|
: `Your ${count} mentors for "${projectTitle}" on MOPC`
|
||||||
|
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'
|
||||||
|
|
||||||
|
const mentorTextLines = mentors
|
||||||
|
.map(
|
||||||
|
(m) => ` • ${m.name ?? 'Mentor'} — ${m.email}`,
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
greeting,
|
||||||
|
'',
|
||||||
|
count === 1
|
||||||
|
? `The mentoring round is now open, and your project "${projectTitle}" has a mentor:`
|
||||||
|
: `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`,
|
||||||
|
'',
|
||||||
|
mentorTextLines,
|
||||||
|
'',
|
||||||
|
'You can chat with them, share files, and track milestones in your mentor workspace:',
|
||||||
|
workspaceUrl,
|
||||||
|
'',
|
||||||
|
'Feel free to reach out to them directly by email as well.',
|
||||||
|
'',
|
||||||
|
'The MOPC team',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const mentorHtmlList = mentors
|
||||||
|
.map(
|
||||||
|
(m) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;font-weight:600;color:#0f172a;">${escapeHtml(m.name ?? 'Mentor')}</td>
|
||||||
|
<td style="padding:6px 0;">
|
||||||
|
<a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||||
|
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||||
|
<h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'Meet your mentor' : `Meet your ${count} mentors`}</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||||
|
<p style="margin-top:0;">${recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,'}</p>
|
||||||
|
<p>${count === 1
|
||||||
|
? `The mentoring round is now open and a mentor has been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`
|
||||||
|
: `The mentoring round is now open and <strong>${count}</strong> mentors have been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`}</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:12px 0 20px;font-size:14px;">${mentorHtmlList}</table>
|
||||||
|
<p style="margin-top:24px;">
|
||||||
|
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Workspace</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||||
|
You can chat with them, share files, and track milestones in the workspace — or reach out to them directly by email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Introduce a project team to their assigned mentor(s), with each mentor's
|
||||||
|
* name + email so the team can reach out directly. Sent when the MENTORING
|
||||||
|
* round opens AND any time a mentor is added to a project whose mentoring
|
||||||
|
* round is already open. Never throws.
|
||||||
|
*/
|
||||||
|
export async function sendTeamMentorIntroductionEmail(
|
||||||
|
recipientEmail: string,
|
||||||
|
recipientName: string | null,
|
||||||
|
projectTitle: string,
|
||||||
|
projectId: string,
|
||||||
|
mentors: { name: string | null; email: string }[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (mentors.length === 0) return
|
||||||
|
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
|
||||||
|
const workspaceUrl = `${baseUrl}/applicant/mentor`
|
||||||
|
const template = getTeamMentorIntroductionTemplate(
|
||||||
|
recipientName,
|
||||||
|
projectTitle,
|
||||||
|
mentors,
|
||||||
|
workspaceUrl,
|
||||||
|
)
|
||||||
|
await sendEmail({
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: template.subject,
|
||||||
|
text: template.text,
|
||||||
|
html: template.html,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentorBulkAssignmentTemplate(
|
||||||
|
name: string,
|
||||||
|
projects: { title: string; url: string }[],
|
||||||
|
mentorDashboardUrl: string,
|
||||||
|
): EmailTemplate {
|
||||||
|
const count = projects.length
|
||||||
|
const subject =
|
||||||
|
count === 1
|
||||||
|
? `You've been assigned to a new MOPC project: "${projects[0].title}"`
|
||||||
|
: `You've been assigned to ${count} new MOPC projects`
|
||||||
|
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||||
|
|
||||||
|
const textLines = projects
|
||||||
|
.map((p) => ` • ${p.title} — ${p.url}`)
|
||||||
|
.join('\n')
|
||||||
|
const text = [
|
||||||
|
greeting,
|
||||||
|
'',
|
||||||
|
count === 1
|
||||||
|
? `You have been assigned as a mentor to a new project:`
|
||||||
|
: `You have been assigned as a mentor to ${count} new projects:`,
|
||||||
|
'',
|
||||||
|
textLines,
|
||||||
|
'',
|
||||||
|
'You may have co-mentors on these teams — you can collaborate together in each project workspace.',
|
||||||
|
'',
|
||||||
|
`Open your mentor dashboard: ${mentorDashboardUrl}`,
|
||||||
|
'',
|
||||||
|
'The MOPC team',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const htmlList = projects
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<li style="margin:6px 0;"><a href="${p.url}" style="color:#053d57;text-decoration:none;font-weight:600;">${escapeHtml(p.title)}</a></li>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||||
|
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||||||
|
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||||||
|
<h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'New mentor assignment' : `${count} new mentor assignments`}</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||||||
|
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
|
||||||
|
<p>${count === 1 ? 'You have been assigned as a mentor to a new project:' : `You have been assigned as a mentor to <strong>${count}</strong> new projects:`}</p>
|
||||||
|
<ul style="padding-left:20px;margin:12px 0 20px;">${htmlList}</ul>
|
||||||
|
<p style="margin-top:24px;">
|
||||||
|
<a href="${mentorDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Dashboard</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||||||
|
You may have co-mentors on these teams — you can collaborate together in each project workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a coalesced mentor-assignment email when one mentor receives multiple
|
||||||
|
* project assignments in a single bulk operation. Caller passes the list of
|
||||||
|
* NEW assignments (already filtered to exclude any whose notificationSentAt
|
||||||
|
* was previously set). Never throws.
|
||||||
|
*/
|
||||||
|
export async function sendMentorBulkAssignmentEmail(
|
||||||
|
email: string,
|
||||||
|
name: string | null,
|
||||||
|
projects: { id: string; title: string }[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (projects.length === 0) return
|
||||||
|
const baseUrl = (process.env.NEXTAUTH_URL || 'https://monaco-opc.com').replace(/\/$/, '')
|
||||||
|
const enriched = projects.map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
url: `${baseUrl}/mentor/workspace/${p.id}`,
|
||||||
|
}))
|
||||||
|
const template = getMentorBulkAssignmentTemplate(
|
||||||
|
name || '',
|
||||||
|
enriched,
|
||||||
|
`${baseUrl}/mentor`,
|
||||||
|
)
|
||||||
|
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
||||||
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import {
|
import {
|
||||||
|
sendMentorBulkAssignmentEmail,
|
||||||
sendMentorChangeRequestEmail,
|
sendMentorChangeRequestEmail,
|
||||||
sendMentorTeamAssignmentEmail,
|
sendMentorTeamAssignmentEmail,
|
||||||
|
sendTeamMentorIntroductionEmail,
|
||||||
} from '@/lib/email'
|
} from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
@@ -46,6 +48,83 @@ import {
|
|||||||
verifyMentorUploadToken,
|
verifyMentorUploadToken,
|
||||||
} from '@/lib/mentor-upload-token'
|
} from '@/lib/mentor-upload-token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Throws TRPCError if the given user is neither the assigned mentor
|
||||||
* nor a team member of the project linked to the assignment.
|
* nor a team member of the project linked to the assignment.
|
||||||
@@ -414,6 +493,10 @@ export const mentorRouter = router({
|
|||||||
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
|
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
|
return assignment
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -564,6 +647,160 @@ export const mentorRouter = router({
|
|||||||
return assignment
|
return assignment
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assign ONE mentor to MANY projects in a single transaction. Skips
|
||||||
|
* projects where this mentor is already an active mentor. Sends a single
|
||||||
|
* coalesced email to the mentor listing all newly-assigned projects.
|
||||||
|
* In-app notifications are still per-project so each team is notified.
|
||||||
|
*/
|
||||||
|
bulkAssign: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
mentorId: z.string(),
|
||||||
|
projectIds: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const mentor = await ctx.prisma.user.findUnique({
|
||||||
|
where: { id: input.mentorId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
roles: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!mentor || !mentor.roles.includes('MENTOR')) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Selected user is not a mentor',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: input.projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
mentorAssignments: {
|
||||||
|
where: { mentorId: mentor.id, droppedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newProjects: { id: string; title: string }[] = []
|
||||||
|
const skippedProjects: { id: string; title: string }[] = []
|
||||||
|
const createdAssignmentIds: string[] = []
|
||||||
|
|
||||||
|
for (const p of projects) {
|
||||||
|
if (p.mentorAssignments.length > 0) {
|
||||||
|
skippedProjects.push({ id: p.id, title: p.title })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const created = await ctx.prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
|
projectId: p.id,
|
||||||
|
mentorId: mentor.id,
|
||||||
|
method: 'MANUAL',
|
||||||
|
assignedBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
createdAssignmentIds.push(created.id)
|
||||||
|
newProjects.push({ id: p.id, title: p.title })
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: mentor.id,
|
||||||
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
||||||
|
title: 'New Mentee Assigned',
|
||||||
|
message: `You have been assigned to mentor "${p.title}".`,
|
||||||
|
linkUrl: `/mentor/projects/${p.id}`,
|
||||||
|
linkLabel: 'View Project',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: { projectName: p.title },
|
||||||
|
})
|
||||||
|
|
||||||
|
await notifyProjectTeam(p.id, {
|
||||||
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
||||||
|
title: 'Mentor Assigned',
|
||||||
|
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
|
||||||
|
linkUrl: `/team/projects/${p.id}`,
|
||||||
|
linkLabel: 'View Project',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: { projectName: p.title, mentorName: mentor.name },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger MENTORING round IN_PROGRESS state transition (best-effort)
|
||||||
|
try {
|
||||||
|
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: p.id,
|
||||||
|
round: {
|
||||||
|
roundType: 'MENTORING',
|
||||||
|
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
|
||||||
|
},
|
||||||
|
state: 'PENDING',
|
||||||
|
},
|
||||||
|
select: { roundId: true },
|
||||||
|
})
|
||||||
|
if (mentoringPrs) {
|
||||||
|
await triggerInProgressOnActivity(
|
||||||
|
p.id,
|
||||||
|
mentoringPrs.roundId,
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.prisma,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One coalesced email per mentor, with all NEW project assignments.
|
||||||
|
if (newProjects.length > 0 && mentor.email) {
|
||||||
|
await sendMentorBulkAssignmentEmail(mentor.email, mentor.name, newProjects)
|
||||||
|
// Stamp notificationSentAt on every row we just created so single-
|
||||||
|
// assignment retries don't re-notify.
|
||||||
|
await ctx.prisma.mentorAssignment.updateMany({
|
||||||
|
where: { id: { in: createdAssignmentIds } },
|
||||||
|
data: { notificationSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each newly-assigned project whose MENTORING round is already open,
|
||||||
|
// introduce the team to the mentor(s) by email.
|
||||||
|
for (const p of newProjects) {
|
||||||
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'MENTOR_BULK_ASSIGN',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: mentor.id,
|
||||||
|
detailsJson: {
|
||||||
|
mentorEmail: mentor.email,
|
||||||
|
assignedCount: newProjects.length,
|
||||||
|
skippedCount: skippedProjects.length,
|
||||||
|
newProjectIds: newProjects.map((p) => p.id),
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignedCount: newProjects.length,
|
||||||
|
skippedCount: skippedProjects.length,
|
||||||
|
skippedProjects,
|
||||||
|
emailSent: newProjects.length > 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove mentor assignment.
|
* Remove mentor assignment.
|
||||||
*
|
*
|
||||||
@@ -842,6 +1079,18 @@ export const mentorRouter = router({
|
|||||||
let assigned = 0
|
let assigned = 0
|
||||||
let unassignable = 0
|
let unassignable = 0
|
||||||
|
|
||||||
|
// 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) {
|
for (const { project } of projectStates) {
|
||||||
try {
|
try {
|
||||||
let mentorId: string | null = null
|
let mentorId: string | null = null
|
||||||
@@ -883,7 +1132,7 @@ export const mentorRouter = router({
|
|||||||
aiReasoning,
|
aiReasoning,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
mentor: { select: { id: true, name: true } },
|
mentor: { select: { id: true, name: true, email: true } },
|
||||||
project: { select: { title: true } },
|
project: { select: { title: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -921,6 +1170,17 @@ export const mentorRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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++
|
assigned++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -932,6 +1192,46 @@ export const mentorRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send one coalesced email per mentor, then stamp notificationSentAt so
|
||||||
|
// re-running the bulk doesn't double-notify.
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the mentoring round is already open at the time of bulk auto-fill,
|
||||||
|
// introduce each team to their new mentor(s). If the round is still
|
||||||
|
// DRAFT, the activation hook will email later.
|
||||||
|
const roundStatus = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
if (roundStatus?.status === 'ROUND_ACTIVE') {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const skipped = await ctx.prisma.projectRoundState.count({
|
const skipped = await ctx.prisma.projectRoundState.count({
|
||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
|
|||||||
@@ -266,6 +266,55 @@ export const roundRouter = router({
|
|||||||
return { count, eligibleTotal, mentorPoolSize }
|
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 },
|
||||||
|
})
|
||||||
|
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.
|
* List projects in a MENTORING round with their (multi-)mentor assignments.
|
||||||
* Drives the per-team assignment table on the round Projects tab so admins
|
* Drives the per-team assignment table on the round Projects tab so admins
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { logAudit } from '@/server/utils/audit'
|
|||||||
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
||||||
import { expireIntentsForRound } from './assignment-intent'
|
import { expireIntentsForRound } from './assignment-intent'
|
||||||
import { processRoundClose } from './round-finalization'
|
import { processRoundClose } from './round-finalization'
|
||||||
|
import { sendTeamMentorIntroductionEmail } from '@/lib/email'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -211,6 +212,86 @@ export async function activateRound(
|
|||||||
} catch (mentoringError) {
|
} catch (mentoringError) {
|
||||||
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user