diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 946ef76..2e04ebc 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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 + 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 */ diff --git a/tests/unit/mentor-round-stats.test.ts b/tests/unit/mentor-round-stats.test.ts new file mode 100644 index 0000000..da9920e --- /dev/null +++ b/tests/unit/mentor-round-stats.test.ts @@ -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) + }) +})