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
|
||||
*/
|
||||
|
||||
244
tests/unit/mentor-round-stats.test.ts
Normal file
244
tests/unit/mentor-round-stats.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { prisma, createCaller } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestProject,
|
||||
createTestCompetition,
|
||||
createTestRound,
|
||||
cleanupTestData,
|
||||
uid,
|
||||
} from '../helpers'
|
||||
import { mentorRouter } from '../../src/server/routers/mentor'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
async function createUserWithRoles(
|
||||
primaryRole: UserRole,
|
||||
rolesArray: UserRole[],
|
||||
overrides: { expertiseTags?: string[]; maxAssignments?: number | null; country?: string | null } = {},
|
||||
) {
|
||||
const id = uid('user')
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: `${id}@test.local`,
|
||||
name: `Test ${primaryRole}`,
|
||||
role: primaryRole,
|
||||
roles: rolesArray,
|
||||
status: 'ACTIVE',
|
||||
expertiseTags: overrides.expertiseTags ?? [],
|
||||
maxAssignments: overrides.maxAssignments ?? null,
|
||||
country: overrides.country ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('mentor.getRoundStats', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const programId of programIds) {
|
||||
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns counts of total / requested / assigned / awaiting + activity totals', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `round-stats-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||
const round = await createTestRound(competition.id, {
|
||||
roundType: 'MENTORING',
|
||||
configJson: { eligibility: 'requested_only', mentoringRequestDeadlineDays: 14 },
|
||||
windowOpenAt: new Date('2026-04-01T00:00:00Z'),
|
||||
})
|
||||
|
||||
const projReqAssigned = await createTestProject(program.id, { title: 'Req+Assigned', tags: [] })
|
||||
const projReqAwaiting = await createTestProject(program.id, { title: 'Req+Awaiting', tags: [] })
|
||||
const projNotReqAssigned = await createTestProject(program.id, { title: 'NotReq+Assigned', tags: [] })
|
||||
const projNotReq = await createTestProject(program.id, { title: 'NotReq', tags: [] })
|
||||
await prisma.project.update({ where: { id: projReqAssigned.id }, data: { wantsMentorship: true } })
|
||||
await prisma.project.update({ where: { id: projReqAwaiting.id }, data: { wantsMentorship: true } })
|
||||
|
||||
for (const p of [projReqAssigned, projReqAwaiting, projNotReqAssigned, projNotReq]) {
|
||||
await prisma.projectRoundState.create({
|
||||
data: { projectId: p.id, roundId: round.id, state: 'PENDING' },
|
||||
})
|
||||
}
|
||||
|
||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(mentor.id)
|
||||
|
||||
const a1 = await prisma.mentorAssignment.create({
|
||||
data: { projectId: projReqAssigned.id, mentorId: mentor.id, method: 'MANUAL', assignedBy: admin.id, workspaceEnabled: true },
|
||||
})
|
||||
await prisma.mentorAssignment.create({
|
||||
data: { projectId: projNotReqAssigned.id, mentorId: mentor.id, method: 'MANUAL', assignedBy: admin.id, workspaceEnabled: true },
|
||||
})
|
||||
await prisma.mentorMessage.create({
|
||||
data: { projectId: projReqAssigned.id, senderId: mentor.id, message: 'hello', workspaceId: a1.id },
|
||||
})
|
||||
await prisma.mentorMessage.create({
|
||||
data: { projectId: projReqAssigned.id, senderId: mentor.id, message: 'still here', workspaceId: a1.id },
|
||||
})
|
||||
await prisma.mentorMessage.create({
|
||||
data: { projectId: projNotReqAssigned.id, senderId: mentor.id, message: 'on it' },
|
||||
})
|
||||
await prisma.mentorFile.create({
|
||||
data: {
|
||||
mentorAssignmentId: a1.id,
|
||||
uploadedByUserId: mentor.id,
|
||||
fileName: 'plan.pdf',
|
||||
objectKey: 'k',
|
||||
bucket: 'b',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||
const stats = await caller.getRoundStats({ roundId: round.id })
|
||||
|
||||
expect(stats.totalProjects).toBe(4)
|
||||
expect(stats.requestedCount).toBe(2)
|
||||
expect(stats.assignedCount).toBe(2)
|
||||
expect(stats.awaitingAssignment).toBe(1)
|
||||
expect(stats.workspaceActivity.messageCount).toBe(3)
|
||||
expect(stats.workspaceActivity.fileCount).toBe(1)
|
||||
expect(stats.workspaceActivity.lastActivityAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('exposes request-window deadline computed from windowOpenAt + deadlineDays', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `round-stats-window-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||
const round = await createTestRound(competition.id, {
|
||||
roundType: 'MENTORING',
|
||||
configJson: { eligibility: 'requested_only', mentoringRequestDeadlineDays: 7 },
|
||||
windowOpenAt: new Date('2026-05-01T00:00:00Z'),
|
||||
})
|
||||
|
||||
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||
const stats = await caller.getRoundStats({ roundId: round.id })
|
||||
|
||||
expect(stats.requestWindow.deadline).not.toBeNull()
|
||||
expect(new Date(stats.requestWindow.deadline!).toISOString()).toBe('2026-05-08T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('returns zeros for an empty round', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `round-stats-empty-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||
const round = await createTestRound(competition.id, {
|
||||
roundType: 'MENTORING',
|
||||
configJson: { eligibility: 'requested_only' },
|
||||
})
|
||||
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||
const stats = await caller.getRoundStats({ roundId: round.id })
|
||||
expect(stats.totalProjects).toBe(0)
|
||||
expect(stats.requestedCount).toBe(0)
|
||||
expect(stats.assignedCount).toBe(0)
|
||||
expect(stats.awaitingAssignment).toBe(0)
|
||||
expect(stats.workspaceActivity.messageCount).toBe(0)
|
||||
expect(stats.workspaceActivity.fileCount).toBe(0)
|
||||
expect(stats.workspaceActivity.lastActivityAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentor.getMentorPool', () => {
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const programId of programIds) {
|
||||
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns all MENTOR-role users with current/completed counts and capacity', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `pool-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
|
||||
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], {
|
||||
expertiseTags: ['biology'],
|
||||
maxAssignments: 5,
|
||||
country: 'France',
|
||||
})
|
||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['policy'] })
|
||||
userIds.push(m1.id, m2.id)
|
||||
|
||||
const project = await createTestProject(program.id, { title: 'P1', tags: [] })
|
||||
await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: project.id, mentorId: m1.id, method: 'MANUAL',
|
||||
assignedBy: admin.id, completionStatus: 'in_progress',
|
||||
},
|
||||
})
|
||||
|
||||
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||
const result = await caller.getMentorPool({})
|
||||
|
||||
const mentor1 = result.mentors.find((m: { id: string }) => m.id === m1.id)!
|
||||
const mentor2 = result.mentors.find((m: { id: string }) => m.id === m2.id)!
|
||||
expect(mentor1).toBeDefined()
|
||||
expect(mentor2).toBeDefined()
|
||||
expect(mentor1.currentAssignments).toBe(1)
|
||||
expect(mentor1.completedAssignments).toBe(0)
|
||||
expect(mentor1.maxAssignments).toBe(5)
|
||||
expect(mentor1.capacityRemaining).toBe(4)
|
||||
expect(mentor1.country).toBe('France')
|
||||
expect(mentor2.currentAssignments).toBe(0)
|
||||
expect(mentor2.maxAssignments).toBeNull()
|
||||
expect(mentor2.capacityRemaining).toBeNull()
|
||||
|
||||
expect(result.poolSize).toBeGreaterThanOrEqual(2)
|
||||
expect(result.totalCurrentAssignments).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('counts completed assignments separately from in-progress', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
const program = await createTestProgram({ name: `pool-completed-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
|
||||
const m = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||
userIds.push(m.id)
|
||||
|
||||
const projInProgress = await createTestProject(program.id, { title: 'A', tags: [] })
|
||||
const projCompleted = await createTestProject(program.id, { title: 'B', tags: [] })
|
||||
await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: projInProgress.id, mentorId: m.id, method: 'MANUAL',
|
||||
assignedBy: admin.id, completionStatus: 'in_progress',
|
||||
},
|
||||
})
|
||||
await prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: projCompleted.id, mentorId: m.id, method: 'MANUAL',
|
||||
assignedBy: admin.id, completionStatus: 'completed',
|
||||
},
|
||||
})
|
||||
|
||||
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||
const result = await caller.getMentorPool({})
|
||||
const found = result.mentors.find((x: { id: string }) => x.id === m.id)!
|
||||
expect(found.currentAssignments).toBe(1)
|
||||
expect(found.completedAssignments).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user