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())
|
assignedAt DateTime @default(now())
|
||||||
assignedBy String? // Admin who assigned
|
assignedBy String? // Admin who assigned
|
||||||
|
|
||||||
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
|
// Per-assignment email idempotency: stamped once the MENTOR-side notification
|
||||||
|
// email has been sent (the "you've been assigned a project" email to the mentor).
|
||||||
notificationSentAt DateTime?
|
notificationSentAt DateTime?
|
||||||
|
|
||||||
|
// Stamped once the TEAM has been introduced to this mentor (the "meet your
|
||||||
|
// mentor" email with mentor contact info). Fired by `activateRound` for
|
||||||
|
// MENTORING rounds and by mentor.assign when the project's MENTORING round
|
||||||
|
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
|
||||||
|
teamIntroducedAt DateTime?
|
||||||
|
|
||||||
// AI assignment metadata
|
// AI assignment metadata
|
||||||
aiConfidenceScore Float?
|
aiConfidenceScore Float?
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
|
|||||||
@@ -976,17 +976,39 @@ export default function MemberDetailPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (!pendingAdditionalRole) return
|
if (!pendingAdditionalRole) return
|
||||||
const { role: r, action } = pendingAdditionalRole
|
const { role: r, action } = pendingAdditionalRole
|
||||||
if (action === 'add') {
|
const nextAdditional =
|
||||||
setAdditionalRoles((prev) =>
|
action === 'add'
|
||||||
prev.includes(r) ? prev : [...prev, r]
|
? 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 {
|
} catch (error) {
|
||||||
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to update roles',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setPendingAdditionalRole(null)
|
||||||
}
|
}
|
||||||
setPendingAdditionalRole(null)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
|
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -74,6 +75,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
assignmentId: string
|
assignmentId: string
|
||||||
mentorName: string
|
mentorName: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
)
|
||||||
|
|
||||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
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.project.get.invalidate({ id: projectId })
|
||||||
utils.mentor.getCandidates.invalidate({ projectId })
|
utils.mentor.getCandidates.invalidate({ projectId })
|
||||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||||
|
utils.mentor.getMentorPool.invalidate()
|
||||||
setPendingMentorId(null)
|
setPendingMentorId(null)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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({
|
const unassignMutation = trpc.mentor.unassign.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Mentor removed')
|
toast.success('Mentor removed')
|
||||||
@@ -383,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{candidatesLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
@@ -400,6 +464,28 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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>Mentor</TableHead>
|
||||||
<TableHead>Expertise</TableHead>
|
<TableHead>Expertise</TableHead>
|
||||||
<TableHead>Country</TableHead>
|
<TableHead>Country</TableHead>
|
||||||
@@ -410,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredCandidates.map((c) => (
|
{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>
|
<TableCell>
|
||||||
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
|
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
|
||||||
<div className="text-muted-foreground text-xs">{c.email}</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 { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
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 { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
@@ -168,6 +169,10 @@ function MentoringBulkAssignToolbar({
|
|||||||
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
|
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
const count = pending?.count ?? 0
|
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({
|
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -190,23 +195,41 @@ function MentoringBulkAssignToolbar({
|
|||||||
— auto-fill is disabled. Assign each project manually.
|
— auto-fill is disabled. Assign each project manually.
|
||||||
</span>
|
</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 ? (
|
) : count > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-medium">{count}</span>{' '}
|
<span className="font-medium">{count}</span>{' '}
|
||||||
<span className="text-muted-foreground">
|
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
All eligible projects have a mentor.
|
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
|
||||||
|
already have a mentor.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => bulk.mutate({ roundId })}
|
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}
|
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
Auto-fill remaining
|
Auto-fill remaining
|
||||||
@@ -1242,17 +1265,32 @@ export default function RoundDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href={poolLink}>
|
{isMentoring ? (
|
||||||
<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">
|
<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" />
|
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Assign Projects</p>
|
<p className="text-sm font-medium">Assign Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setActiveTab('projects')}
|
onClick={() => setActiveTab('projects')}
|
||||||
@@ -1570,19 +1608,29 @@ export default function RoundDetailPage() {
|
|||||||
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
{/* ═══════════ PROJECTS TAB ═══════════ */}
|
||||||
<TabsContent value="projects" className="space-y-4">
|
<TabsContent value="projects" className="space-y-4">
|
||||||
{isMentoring && (
|
{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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ FILTERING TAB ═══════════ */}
|
{/* ═══════════ FILTERING TAB ═══════════ */}
|
||||||
|
|||||||
@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
|
{(() => {
|
||||||
{project.mentorAssignments?.[0]?.mentor && (
|
type MentorAssignment = {
|
||||||
<div className="rounded-lg border p-3 bg-muted/50">
|
droppedAt: Date | string | null
|
||||||
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
mentor: { name: string | null; email: string } | null
|
||||||
<p className="text-sm text-muted-foreground">
|
}
|
||||||
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
|
const active = (
|
||||||
</p>
|
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
|
||||||
</div>
|
).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 */}
|
{/* Tags */}
|
||||||
{project.tags && project.tags.length > 0 && (
|
{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.
|
* 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.
|
* From Pool: search existing projects not yet in this round and assign them.
|
||||||
*/
|
*/
|
||||||
function AddProjectDialog({
|
export function AddProjectDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 || ''
|
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
|
||||||
|
|
||||||
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
|
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 { transporter, from } = await getTransporter()
|
||||||
const to = DEV_EMAIL_OVERRIDE || opts.to
|
const to = DEV_EMAIL_OVERRIDE || opts.to
|
||||||
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
|
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
|
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
||||||
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import {
|
import {
|
||||||
|
sendMentorBulkAssignmentEmail,
|
||||||
sendMentorChangeRequestEmail,
|
sendMentorChangeRequestEmail,
|
||||||
sendMentorTeamAssignmentEmail,
|
sendMentorTeamAssignmentEmail,
|
||||||
|
sendTeamMentorIntroductionEmail,
|
||||||
} from '@/lib/email'
|
} from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
@@ -46,6 +48,104 @@ import {
|
|||||||
verifyMentorUploadToken,
|
verifyMentorUploadToken,
|
||||||
} from '@/lib/mentor-upload-token'
|
} 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
|
* Throws TRPCError if the given user is neither the assigned mentor
|
||||||
* nor a team member of the project linked to the assignment.
|
* nor a team member of the project linked to the assignment.
|
||||||
@@ -270,6 +370,25 @@ export const mentorRouter = router({
|
|||||||
where: { id: input.projectId },
|
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
|
// Verify mentor exists
|
||||||
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
||||||
where: { id: input.mentorId },
|
where: { id: input.mentorId },
|
||||||
@@ -346,6 +465,15 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor of new mentee
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: input.mentorId,
|
userId: input.mentorId,
|
||||||
@@ -360,6 +488,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team of mentor assignment
|
// Notify project team of mentor assignment
|
||||||
@@ -374,13 +503,14 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send per-team email notification once per assignment row. Idempotency
|
if (
|
||||||
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
|
!deferThisEmail &&
|
||||||
// it null. If the same mentor is later dropped and re-assigned (new row,
|
assignment.notificationSentAt == null &&
|
||||||
// fresh id), a new email is sent — intentional.
|
assignment.mentor.email
|
||||||
if (assignment.notificationSentAt == null && assignment.mentor.email) {
|
) {
|
||||||
await sendMentorTeamAssignmentEmail(
|
await sendMentorTeamAssignmentEmail(
|
||||||
assignment.mentor.email,
|
assignment.mentor.email,
|
||||||
assignment.mentor.name,
|
assignment.mentor.name,
|
||||||
@@ -414,6 +544,10 @@ export const mentorRouter = router({
|
|||||||
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
|
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the project's MENTORING round is already open, introduce the team
|
||||||
|
// to their mentor(s) by email now. Otherwise the activation hook fires it.
|
||||||
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
|
||||||
|
|
||||||
return assignment
|
return assignment
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -531,6 +665,13 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor of new mentee
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: mentorId,
|
userId: mentorId,
|
||||||
@@ -545,6 +686,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team of mentor assignment
|
// Notify project team of mentor assignment
|
||||||
@@ -559,11 +701,258 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
return assignment
|
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.
|
* Remove mentor assignment.
|
||||||
*
|
*
|
||||||
@@ -714,6 +1103,12 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: mentorId,
|
userId: mentorId,
|
||||||
@@ -728,6 +1123,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team
|
// Notify project team
|
||||||
@@ -742,6 +1138,7 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
assigned++
|
assigned++
|
||||||
@@ -795,7 +1192,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { id: true, roundType: true, configJson: true },
|
select: { id: true, roundType: true, configJson: true, status: true },
|
||||||
})
|
})
|
||||||
if (round.roundType !== 'MENTORING') {
|
if (round.roundType !== 'MENTORING') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -818,7 +1215,7 @@ export const mentorRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
project: {
|
project: {
|
||||||
mentorAssignments: { none: {} },
|
mentorAssignments: { none: { droppedAt: null } },
|
||||||
// Only assign mentors to projects whose team has confirmed they will
|
// Only assign mentors to projects whose team has confirmed they will
|
||||||
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
|
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
|
||||||
// confirmations and any project without a confirmation row at all.
|
// confirmations and any project without a confirmation row at all.
|
||||||
@@ -842,6 +1239,23 @@ export const mentorRouter = router({
|
|||||||
let assigned = 0
|
let assigned = 0
|
||||||
let unassignable = 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) {
|
for (const { project } of projectStates) {
|
||||||
try {
|
try {
|
||||||
let mentorId: string | null = null
|
let mentorId: string | null = null
|
||||||
@@ -883,7 +1297,7 @@ export const mentorRouter = router({
|
|||||||
aiReasoning,
|
aiReasoning,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
mentor: { select: { id: true, name: true } },
|
mentor: { select: { id: true, name: true, email: true } },
|
||||||
project: { select: { title: true } },
|
project: { select: { title: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -906,6 +1320,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferEmailsForRound,
|
||||||
})
|
})
|
||||||
|
|
||||||
await notifyProjectTeam(project.id, {
|
await notifyProjectTeam(project.id, {
|
||||||
@@ -919,8 +1334,20 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
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++
|
assigned++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
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({
|
const skipped = await ctx.prisma.projectRoundState.count({
|
||||||
where: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
|
|||||||
@@ -227,21 +227,169 @@ export const roundRouter = router({
|
|||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { roundType: true, configJson: true },
|
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 config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
const eligibility = (config.eligibility as string) ?? 'requested_only'
|
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: {
|
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: {
|
project: {
|
||||||
mentorAssignments: { none: {} },
|
select: {
|
||||||
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
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>
|
metadata?: Record<string, unknown>
|
||||||
groupKey?: string
|
groupKey?: string
|
||||||
expiresAt?: Date
|
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,
|
metadata,
|
||||||
groupKey,
|
groupKey,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
skipEmail,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
// Determine icon and priority if not provided
|
// Determine icon and priority if not provided
|
||||||
@@ -241,8 +250,11 @@ export async function createNotification(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if we should also send an email
|
// Check if we should also send an email (suppressed when the caller is
|
||||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
// 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
|
icon?: string
|
||||||
priority?: NotificationPriority
|
priority?: NotificationPriority
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
/** See {@link CreateNotificationParams.skipEmail}. */
|
||||||
|
skipEmail?: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
userIds,
|
userIds,
|
||||||
@@ -269,6 +283,7 @@ export async function createBulkNotifications(params: {
|
|||||||
icon,
|
icon,
|
||||||
priority,
|
priority,
|
||||||
metadata,
|
metadata,
|
||||||
|
skipEmail,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
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
|
// Check email settings once, then send emails only if enabled
|
||||||
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||||
where: { notificationType: type },
|
where: { notificationType: type },
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { logAudit } from '@/server/utils/audit'
|
|||||||
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
||||||
import { expireIntentsForRound } from './assignment-intent'
|
import { expireIntentsForRound } from './assignment-intent'
|
||||||
import { processRoundClose } from './round-finalization'
|
import { processRoundClose } from './round-finalization'
|
||||||
|
import {
|
||||||
|
sendMentorBulkAssignmentEmail,
|
||||||
|
sendTeamMentorIntroductionEmail,
|
||||||
|
} from '@/lib/email'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -211,6 +215,150 @@ export async function activateRound(
|
|||||||
} catch (mentoringError) {
|
} catch (mentoringError) {
|
||||||
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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', () => {
|
describe('mentor.assign — stacking + per-team email idempotency', () => {
|
||||||
const programIds: string[] = []
|
const programIds: string[] = []
|
||||||
const userIds: string[] = []
|
const userIds: string[] = []
|
||||||
@@ -62,6 +93,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
|
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Stacking Project' })
|
const project = await createTestProject(program.id, { title: 'Stacking Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
|
|
||||||
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
|
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
|
||||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
|
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()}` })
|
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Dup Project' })
|
const project = await createTestProject(program.id, { title: 'Dup Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
@@ -114,7 +147,9 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-email-${uid()}` })
|
const program = await createTestProgram({ name: `assign-email-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
|
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
|
||||||
|
await attachToMentoringRound(program.id, project1.id)
|
||||||
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
|
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
|
||||||
|
await attachToMentoringRound(program.id, project2.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
@@ -144,6 +179,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
|
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
|
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 m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
|
||||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
|
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
|
||||||
userIds.push(m1.id, m2.id)
|
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()}` })
|
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
|
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user