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:
Matt
2026-04-28 15:24:07 +02:00
parent 64668b047e
commit f9bffabf05
2 changed files with 456 additions and 0 deletions

View File

@@ -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
*/