Compare commits
6 Commits
5b99d6a530
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03526fca97 | ||
|
|
61dfc608cd | ||
|
|
c4f7216bc1 | ||
|
|
cb2a864b7f | ||
|
|
195fc787a9 | ||
|
|
921019aaa4 |
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "MentorAssignment" ADD COLUMN "teamIntroducedAt" TIMESTAMP(3);
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
setPendingAdditionalRole(null)
|
||||
}}
|
||||
>
|
||||
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,17 +1265,32 @@ 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">
|
||||
<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">
|
||||
{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">
|
||||
Add projects from the pool to this round
|
||||
Open the Projects tab to add or auto-fill teams in this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Assign Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add projects from the pool to this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('projects')}
|
||||
@@ -1570,19 +1608,29 @@ export default function RoundDetailPage() {
|
||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
{isMentoring && (
|
||||
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||
<>
|
||||
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||
<MentoringProjectsTable
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isMentoring && (
|
||||
<ProjectStatesTable
|
||||
competitionId={competitionId}
|
||||
roundId={roundId}
|
||||
roundStatus={round?.status}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
onAssignProjects={() => {
|
||||
setActiveTab('assignments')
|
||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ProjectStatesTable
|
||||
competitionId={competitionId}
|
||||
roundId={roundId}
|
||||
roundStatus={round?.status}
|
||||
competitionRounds={competition?.rounds}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
onAssignProjects={() => {
|
||||
setActiveTab('assignments')
|
||||
setTimeout(() => setPreviewSheetOpen(true), 100)
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||
|
||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
|
||||
{project.mentorAssignments?.[0]?.mentor && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
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">
|
||||
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
|
||||
</p>
|
||||
<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 && (
|
||||
|
||||
738
src/components/admin/round/mentoring-projects-table.tsx
Normal file
738
src/components/admin/round/mentoring-projects-table.tsx
Normal 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'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 “{mentorSearch}”.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
217
src/lib/email.ts
217
src/lib/email.ts
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
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 },
|
||||
})
|
||||
if (round.roundType !== 'MENTORING') {
|
||||
return { priorRound: null, pendingCount: 0 }
|
||||
}
|
||||
const prior = await ctx.prisma.round.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
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: {
|
||||
mentorAssignments: { none: {} },
|
||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||
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 { count }
|
||||
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
||||
// 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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
228
tests/unit/mentor-email-deferral.test.ts
Normal file
228
tests/unit/mentor-email-deferral.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user