feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and creates the cartesian product of (mentor, project) assignments. Existing active (mentor, project) pairs are skipped per-pair, not per-project, so choosing two mentors against a project that already has one of them still adds the second. Email coalescing stays one-per-mentor: each mentor receives a single email listing only their own newly-assigned projects (not the union). Each touched project still triggers a single team-introduction email when its MENTORING round is ROUND_ACTIVE, listing all currently-active mentors on that team. Dialog UI swaps the radio picker for a checkbox group with a removable chip strip for selected mentors, a live preview of the assignment count (mentors × projects = up to N), and a submit button that names both counts. Toast on success reports total assignments created, projects touched, pairs skipped, and how many mentor emails went out.
This commit is contained in:
@@ -43,7 +43,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [bulkOpen, setBulkOpen] = useState(false)
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
const [chosenMentorId, setChosenMentorId] = useState<string>('')
|
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
|
||||||
const [mentorSearch, setMentorSearch] = useState('')
|
const [mentorSearch, setMentorSearch] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -63,17 +63,28 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
|
|
||||||
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (result.assignedCount === 0 && result.skippedCount > 0) {
|
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`,
|
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
|
||||||
toast.success(
|
toast.success(
|
||||||
`Assigned mentor to ${result.assignedCount} project${
|
`Created ${result.totalAssigned} assignment${
|
||||||
result.assignedCount === 1 ? '' : 's'
|
result.totalAssigned === 1 ? '' : 's'
|
||||||
}${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${
|
} across ${result.touchedProjectCount} project${
|
||||||
result.emailSent ? ' · email sent' : ''
|
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.listMentoringProjects.invalidate({ roundId })
|
||||||
@@ -83,7 +94,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
utils.mentor.getRoundStats.invalidate({ roundId })
|
utils.mentor.getRoundStats.invalidate({ roundId })
|
||||||
utils.project.list.invalidate()
|
utils.project.list.invalidate()
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setChosenMentorId('')
|
setChosenMentorIds(new Set())
|
||||||
setMentorSearch('')
|
setMentorSearch('')
|
||||||
setBulkOpen(false)
|
setBulkOpen(false)
|
||||||
},
|
},
|
||||||
@@ -442,7 +453,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setBulkOpen(false)
|
setBulkOpen(false)
|
||||||
setChosenMentorId('')
|
setChosenMentorIds(new Set())
|
||||||
setMentorSearch('')
|
setMentorSearch('')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -450,117 +461,181 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Assign mentor to {selected.size} project
|
Assign mentors to {selected.size} project
|
||||||
{selected.size === 1 ? '' : 's'}
|
{selected.size === 1 ? '' : 's'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose one mentor — they'll receive a single email listing every
|
Tick any number of mentors. Each chosen mentor will be added to
|
||||||
new assignment. Projects where they're already an active mentor
|
every selected project they aren't already on. Each mentor
|
||||||
will be skipped.
|
receives one combined email; each team receives one intro email
|
||||||
|
listing all of their mentors.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="relative">
|
{(() => {
|
||||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
const allMentors = mentorPool?.mentors ?? []
|
||||||
<Input
|
const chosenMentors = allMentors.filter((m) =>
|
||||||
value={mentorSearch}
|
chosenMentorIds.has(m.id),
|
||||||
onChange={(e) => setMentorSearch(e.target.value)}
|
)
|
||||||
placeholder="Search mentor by name, email, country, or expertise…"
|
const upperBound = chosenMentorIds.size * selected.size
|
||||||
className="pl-8"
|
|
||||||
/>
|
return (
|
||||||
</div>
|
<>
|
||||||
<div className="max-h-72 overflow-y-auto rounded-md border">
|
{chosenMentors.length > 0 && (
|
||||||
{(() => {
|
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
|
||||||
const mentors = mentorPool?.mentors ?? []
|
{chosenMentors.map((m) => (
|
||||||
const q = mentorSearch.trim().toLowerCase()
|
<Badge
|
||||||
const filteredMentors = q
|
key={m.id}
|
||||||
? mentors.filter((m) =>
|
variant="secondary"
|
||||||
[
|
className="gap-1 pl-2 pr-1"
|
||||||
m.name ?? '',
|
>
|
||||||
m.email,
|
{m.name ?? m.email}
|
||||||
m.country ?? '',
|
<button
|
||||||
...(m.expertiseTags ?? []),
|
type="button"
|
||||||
]
|
aria-label={`Remove ${m.name ?? m.email}`}
|
||||||
.join(' ')
|
className="rounded-full p-0.5 hover:bg-foreground/10"
|
||||||
.toLowerCase()
|
onClick={() =>
|
||||||
.includes(q),
|
setChosenMentorIds((prev) => {
|
||||||
)
|
const next = new Set(prev)
|
||||||
: mentors
|
next.delete(m.id)
|
||||||
if (mentors.length === 0) {
|
return next
|
||||||
return (
|
})
|
||||||
<p className="p-4 text-center text-sm text-muted-foreground">
|
}
|
||||||
No mentors in the pool yet.{' '}
|
>
|
||||||
<Link
|
<X className="h-3 w-3" />
|
||||||
href="/admin/members?tab=mentors"
|
</button>
|
||||||
className="underline-offset-2 hover:underline"
|
</Badge>
|
||||||
>
|
))}
|
||||||
Add mentors
|
</div>
|
||||||
</Link>
|
)}
|
||||||
.
|
|
||||||
|
<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>
|
</p>
|
||||||
)
|
)}
|
||||||
}
|
</>
|
||||||
if (filteredMentors.length === 0) {
|
)
|
||||||
return (
|
})()}
|
||||||
<p className="p-4 text-center text-sm text-muted-foreground">
|
|
||||||
No mentors match “{mentorSearch}”.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return filteredMentors.map((m) => {
|
|
||||||
const isChosen = chosenMentorId === m.id
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={m.id}
|
|
||||||
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
|
|
||||||
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bulk-mentor"
|
|
||||||
className="mt-1"
|
|
||||||
checked={isChosen}
|
|
||||||
onChange={() => setChosenMentorId(m.id)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="font-medium">
|
|
||||||
{m.name ?? 'Unnamed'}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{m.email}
|
|
||||||
{m.country && <> · {m.country}</>}
|
|
||||||
</div>
|
|
||||||
{m.expertiseTags && m.expertiseTags.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{m.expertiseTags.slice(0, 4).map((t) => (
|
|
||||||
<Badge
|
|
||||||
key={t}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{m.expertiseTags.length > 4 && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
+{m.expertiseTags.length - 4}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
|
||||||
{m.currentAssignments}
|
|
||||||
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
|
|
||||||
load
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -568,7 +643,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBulkOpen(false)
|
setBulkOpen(false)
|
||||||
setChosenMentorId('')
|
setChosenMentorIds(new Set())
|
||||||
setMentorSearch('')
|
setMentorSearch('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -577,16 +652,19 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
bulkAssignMutation.mutate({
|
bulkAssignMutation.mutate({
|
||||||
mentorId: chosenMentorId,
|
mentorIds: Array.from(chosenMentorIds),
|
||||||
projectIds: Array.from(selected),
|
projectIds: Array.from(selected),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!chosenMentorId || bulkAssignMutation.isPending}
|
disabled={
|
||||||
|
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{bulkAssignMutation.isPending && (
|
{bulkAssignMutation.isPending && (
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Assign to {selected.size} project
|
Assign {chosenMentorIds.size} mentor
|
||||||
|
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
|
||||||
{selected.size === 1 ? '' : 's'}
|
{selected.size === 1 ? '' : 's'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -648,33 +648,30 @@ export const mentorRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk-assign ONE mentor to MANY projects in a single transaction. Skips
|
* Bulk-assign MANY mentors to MANY projects (cartesian product) in one
|
||||||
* projects where this mentor is already an active mentor. Sends a single
|
* call. Skips (mentor, project) pairs where the mentor is already an
|
||||||
* coalesced email to the mentor listing all newly-assigned projects.
|
* active mentor on that project. Each affected mentor receives ONE
|
||||||
* In-app notifications are still per-project so each team is notified.
|
* 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
|
bulkAssign: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
mentorId: z.string(),
|
mentorIds: z.array(z.string()).min(1),
|
||||||
projectIds: z.array(z.string()).min(1),
|
projectIds: z.array(z.string()).min(1),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const mentor = await ctx.prisma.user.findUnique({
|
const mentors = await ctx.prisma.user.findMany({
|
||||||
where: { id: input.mentorId },
|
where: { id: { in: input.mentorIds } },
|
||||||
select: {
|
select: { id: true, name: true, email: true, roles: true },
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
roles: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if (!mentor || !mentor.roles.includes('MENTOR')) {
|
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
|
||||||
|
if (validMentors.length === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Selected user is not a mentor',
|
message: 'None of the selected users have the MENTOR role',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,120 +681,169 @@ export const mentorRouter = router({
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
mentorAssignments: {
|
mentorAssignments: {
|
||||||
where: { mentorId: mentor.id, droppedAt: null },
|
where: {
|
||||||
select: { id: true },
|
mentorId: { in: validMentors.map((m) => m.id) },
|
||||||
|
droppedAt: null,
|
||||||
|
},
|
||||||
|
select: { mentorId: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const newProjects: { id: string; title: string }[] = []
|
// Track per-mentor (for emails) and per-project (for team intros) state.
|
||||||
const skippedProjects: { id: string; title: string }[] = []
|
const perMentor = new Map<
|
||||||
const createdAssignmentIds: string[] = []
|
string,
|
||||||
|
{
|
||||||
for (const p of projects) {
|
email: string | null
|
||||||
if (p.mentorAssignments.length > 0) {
|
name: string | null
|
||||||
skippedProjects.push({ id: p.id, title: p.title })
|
assignmentIds: string[]
|
||||||
continue
|
newProjects: { id: string; title: string }[]
|
||||||
|
skippedProjects: { id: string; title: string }[]
|
||||||
}
|
}
|
||||||
const created = await ctx.prisma.mentorAssignment.create({
|
>()
|
||||||
data: {
|
for (const m of validMentors) {
|
||||||
projectId: p.id,
|
perMentor.set(m.id, {
|
||||||
mentorId: mentor.id,
|
email: m.email ?? null,
|
||||||
method: 'MANUAL',
|
name: m.name ?? null,
|
||||||
assignedBy: ctx.user.id,
|
assignmentIds: [],
|
||||||
},
|
newProjects: [],
|
||||||
|
skippedProjects: [],
|
||||||
})
|
})
|
||||||
createdAssignmentIds.push(created.id)
|
}
|
||||||
newProjects.push({ id: p.id, title: p.title })
|
const touchedProjectIds = new Set<string>()
|
||||||
|
let totalAssigned = 0
|
||||||
|
let totalSkipped = 0
|
||||||
|
|
||||||
await createNotification({
|
for (const project of projects) {
|
||||||
userId: mentor.id,
|
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
|
||||||
type: NotificationTypes.MENTEE_ASSIGNED,
|
for (const mentor of validMentors) {
|
||||||
title: 'New Mentee Assigned',
|
const bucket = perMentor.get(mentor.id)!
|
||||||
message: `You have been assigned to mentor "${p.title}".`,
|
if (alreadyOn.has(mentor.id)) {
|
||||||
linkUrl: `/mentor/projects/${p.id}`,
|
bucket.skippedProjects.push({ id: project.id, title: project.title })
|
||||||
linkLabel: 'View Project',
|
totalSkipped++
|
||||||
priority: 'high',
|
continue
|
||||||
metadata: { projectName: p.title },
|
}
|
||||||
})
|
const created = await ctx.prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
await notifyProjectTeam(p.id, {
|
projectId: project.id,
|
||||||
type: NotificationTypes.MENTOR_ASSIGNED,
|
mentorId: mentor.id,
|
||||||
title: 'Mentor Assigned',
|
method: 'MANUAL',
|
||||||
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
|
assignedBy: ctx.user.id,
|
||||||
linkUrl: `/team/projects/${p.id}`,
|
|
||||||
linkLabel: 'View Project',
|
|
||||||
priority: 'high',
|
|
||||||
metadata: { projectName: p.title, mentorName: mentor.name },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger MENTORING round IN_PROGRESS state transition (best-effort)
|
|
||||||
try {
|
|
||||||
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId: p.id,
|
|
||||||
round: {
|
|
||||||
roundType: 'MENTORING',
|
|
||||||
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
|
|
||||||
},
|
|
||||||
state: 'PENDING',
|
|
||||||
},
|
},
|
||||||
select: { roundId: true },
|
|
||||||
})
|
})
|
||||||
if (mentoringPrs) {
|
bucket.assignmentIds.push(created.id)
|
||||||
await triggerInProgressOnActivity(
|
bucket.newProjects.push({ id: project.id, title: project.title })
|
||||||
p.id,
|
touchedProjectIds.add(project.id)
|
||||||
mentoringPrs.roundId,
|
totalAssigned++
|
||||||
ctx.user.id,
|
|
||||||
ctx.prisma,
|
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 },
|
||||||
|
})
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One coalesced email per mentor, with all NEW project assignments.
|
// One email per mentor, listing only their NEW projects.
|
||||||
if (newProjects.length > 0 && mentor.email) {
|
for (const bucket of perMentor.values()) {
|
||||||
await sendMentorBulkAssignmentEmail(mentor.email, mentor.name, newProjects)
|
if (bucket.newProjects.length === 0 || !bucket.email) continue
|
||||||
// Stamp notificationSentAt on every row we just created so single-
|
await sendMentorBulkAssignmentEmail(
|
||||||
// assignment retries don't re-notify.
|
bucket.email,
|
||||||
|
bucket.name,
|
||||||
|
bucket.newProjects,
|
||||||
|
)
|
||||||
await ctx.prisma.mentorAssignment.updateMany({
|
await ctx.prisma.mentorAssignment.updateMany({
|
||||||
where: { id: { in: createdAssignmentIds } },
|
where: { id: { in: bucket.assignmentIds } },
|
||||||
data: { notificationSentAt: new Date() },
|
data: { notificationSentAt: new Date() },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each newly-assigned project whose MENTORING round is already open,
|
// One team-intro email per touched project (only if MENTORING round
|
||||||
// introduce the team to the mentor(s) by email.
|
// is currently ROUND_ACTIVE). The helper lists ALL active mentors on
|
||||||
for (const p of newProjects) {
|
// the project, including any pre-existing co-mentors.
|
||||||
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
|
for (const projectId of touchedProjectIds) {
|
||||||
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'MENTOR_BULK_ASSIGN',
|
action: 'MENTOR_BULK_ASSIGN',
|
||||||
entityType: 'User',
|
entityType: 'BulkAssign',
|
||||||
entityId: mentor.id,
|
entityId: 'multi',
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
mentorEmail: mentor.email,
|
mentorIds: validMentors.map((m) => m.id),
|
||||||
assignedCount: newProjects.length,
|
projectIds: input.projectIds,
|
||||||
skippedCount: skippedProjects.length,
|
totalAssigned,
|
||||||
newProjectIds: newProjects.map((p) => p.id),
|
totalSkipped,
|
||||||
|
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
|
||||||
|
mentorId: id,
|
||||||
|
assigned: b.newProjects.length,
|
||||||
|
skipped: b.skippedProjects.length,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assignedCount: newProjects.length,
|
totalAssigned,
|
||||||
skippedCount: skippedProjects.length,
|
totalSkipped,
|
||||||
skippedProjects,
|
touchedProjectCount: touchedProjectIds.size,
|
||||||
emailSent: newProjects.length > 0,
|
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,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user