feat(mentor): getRoundStats + getMentorPool procedures (§B)
- getRoundStats(roundId): totals + requested/assigned/awaiting counts +
request-window deadline (windowOpenAt + mentoringRequestDeadlineDays) +
workspace activity (msgs / files / milestones / lastActivityAt).
- getMentorPool({programId?}): all MENTOR-role users with current/completed
assignment counts, capacity remaining, last activity. Drives both the
round-overview pool card and the /admin/mentors list page.
- Tests cover empty rounds, mixed-state rounds, and capacity arithmetic.
Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §B
This commit is contained in:
@@ -860,6 +860,218 @@ export const mentorRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* MENTORING-round stats card: totals + request window + workspace activity.
|
||||
* Single round-scoped query set; cheap enough to call uncached.
|
||||
*/
|
||||
getRoundStats: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
roundType: true,
|
||||
configJson: true,
|
||||
windowOpenAt: true,
|
||||
},
|
||||
})
|
||||
if (round.roundType !== 'MENTORING') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Round is not a MENTORING round',
|
||||
})
|
||||
}
|
||||
|
||||
const [
|
||||
totalProjects,
|
||||
requestedCount,
|
||||
assignedAndRequested,
|
||||
totalAssigned,
|
||||
messageCount,
|
||||
fileCount,
|
||||
milestoneCount,
|
||||
latestMessage,
|
||||
latestFile,
|
||||
latestMilestone,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.projectRoundState.count({
|
||||
where: { roundId: input.roundId, project: { wantsMentorship: true } },
|
||||
}),
|
||||
ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
project: { mentorAssignment: { isNot: null } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.mentorMessage.count({
|
||||
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
|
||||
}),
|
||||
ctx.prisma.mentorFile.count({
|
||||
where: {
|
||||
mentorAssignment: {
|
||||
project: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.mentorMilestoneCompletion.count({
|
||||
where: {
|
||||
mentorAssignment: {
|
||||
project: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.mentorMessage.findFirst({
|
||||
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
ctx.prisma.mentorFile.findFirst({
|
||||
where: {
|
||||
mentorAssignment: {
|
||||
project: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
ctx.prisma.mentorMilestoneCompletion.findFirst({
|
||||
where: {
|
||||
mentorAssignment: {
|
||||
project: { projectRoundStates: { some: { roundId: input.roundId } } },
|
||||
},
|
||||
},
|
||||
orderBy: { completedAt: 'desc' },
|
||||
select: { completedAt: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const deadlineDays =
|
||||
typeof config.mentoringRequestDeadlineDays === 'number'
|
||||
? config.mentoringRequestDeadlineDays
|
||||
: 14
|
||||
const deadline = round.windowOpenAt
|
||||
? new Date(round.windowOpenAt.getTime() + deadlineDays * 86_400_000)
|
||||
: null
|
||||
|
||||
const lastActivityAt =
|
||||
[latestMessage?.createdAt, latestFile?.createdAt, latestMilestone?.completedAt]
|
||||
.filter((d): d is Date => Boolean(d))
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
|
||||
|
||||
return {
|
||||
totalProjects,
|
||||
requestedCount,
|
||||
assignedCount: totalAssigned,
|
||||
awaitingAssignment: Math.max(0, requestedCount - assignedAndRequested),
|
||||
requestWindow: {
|
||||
deadline,
|
||||
deadlineDays,
|
||||
},
|
||||
workspaceActivity: {
|
||||
messageCount,
|
||||
fileCount,
|
||||
milestoneCount,
|
||||
lastActivityAt,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* All MENTOR-role users with current/completed assignment counts, capacity,
|
||||
* country, expertise, and last activity. Drives the /admin/mentors list page
|
||||
* and the round-overview pool card.
|
||||
*/
|
||||
getMentorPool: adminProcedure
|
||||
.input(z.object({ programId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const mentors = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
roles: { has: 'MENTOR' },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
country: true,
|
||||
expertiseTags: true,
|
||||
maxAssignments: true,
|
||||
mentorAssignments: {
|
||||
where: input.programId ? { project: { programId: input.programId } } : undefined,
|
||||
select: {
|
||||
completionStatus: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
files: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
milestoneCompletions: {
|
||||
orderBy: { completedAt: 'desc' },
|
||||
take: 1,
|
||||
select: { completedAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
let totalCurrentAssignments = 0
|
||||
|
||||
const enriched = mentors.map((m) => {
|
||||
let current = 0
|
||||
let completed = 0
|
||||
const activityDates: Date[] = []
|
||||
for (const a of m.mentorAssignments) {
|
||||
if (a.completionStatus === 'completed') completed++
|
||||
else current++
|
||||
if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
|
||||
if (a.files[0]) activityDates.push(a.files[0].createdAt)
|
||||
if (a.milestoneCompletions[0])
|
||||
activityDates.push(a.milestoneCompletions[0].completedAt)
|
||||
}
|
||||
totalCurrentAssignments += current
|
||||
const lastActivityAt =
|
||||
activityDates.length > 0
|
||||
? activityDates.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
: null
|
||||
const capacityRemaining =
|
||||
m.maxAssignments != null ? Math.max(0, m.maxAssignments - current) : null
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
country: m.country,
|
||||
expertiseTags: m.expertiseTags,
|
||||
currentAssignments: current,
|
||||
completedAssignments: completed,
|
||||
maxAssignments: m.maxAssignments,
|
||||
capacityRemaining,
|
||||
lastActivityAt,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mentors: enriched,
|
||||
poolSize: enriched.length,
|
||||
totalCurrentAssignments,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get mentor's assigned projects
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user