feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
This commit is contained in:
@@ -266,6 +266,55 @@ export const roundRouter = router({
|
||||
return { count, eligibleTotal, mentorPoolSize }
|
||||
}),
|
||||
|
||||
/**
|
||||
* For a MENTORING round, find the immediately-prior round in the same
|
||||
* competition and report how many of its PASSED projects are not yet
|
||||
* present in this round. Drives the "Import from prior round" CTA so
|
||||
* admins don't have to manually pick projects via the From-Round modal.
|
||||
*/
|
||||
getMentoringImportCandidates: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { roundType: true, competitionId: true, sortOrder: true },
|
||||
})
|
||||
if (round.roundType !== 'MENTORING') {
|
||||
return { priorRound: null, pendingCount: 0 }
|
||||
}
|
||||
const prior = await ctx.prisma.round.findFirst({
|
||||
where: {
|
||||
competitionId: round.competitionId,
|
||||
sortOrder: { lt: round.sortOrder },
|
||||
},
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
select: { id: true, name: true, status: true },
|
||||
})
|
||||
if (!prior) return { priorRound: null, pendingCount: 0 }
|
||||
if (prior.status !== 'ROUND_ACTIVE' && prior.status !== 'ROUND_CLOSED') {
|
||||
return {
|
||||
priorRound: { id: prior.id, name: prior.name, status: prior.status },
|
||||
pendingCount: 0,
|
||||
}
|
||||
}
|
||||
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const existingIds = new Set(existingInTarget.map((s) => s.projectId))
|
||||
const passedInPrior = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: prior.id, state: 'PASSED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const pendingCount = passedInPrior.filter(
|
||||
(s) => !existingIds.has(s.projectId),
|
||||
).length
|
||||
return {
|
||||
priorRound: { id: prior.id, name: prior.name, status: prior.status },
|
||||
pendingCount,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* List projects in a MENTORING round with their (multi-)mentor assignments.
|
||||
* Drives the per-team assignment table on the round Projects tab so admins
|
||||
|
||||
Reference in New Issue
Block a user