diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 0cbaac9..433d7dc 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -1096,6 +1096,153 @@ export const finalistRouter = router({ return { ok: true } }), + /** + * List all MENTORING-round projects as enrollment candidates, grouped by + * competitionCategory. Each candidate includes team members, inLiveFinal flag, + * confirmationStatus, and per-category quota + confirmed/pending counts. + * Drives the Finalist Enrollment Card on the LIVE_FINAL round Overview page. + */ + listEnrollmentCandidates: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + // Resolve program (for attendeeCap) and its competitions' rounds + const program = await ctx.prisma.program.findUniqueOrThrow({ + where: { id: input.programId }, + select: { defaultAttendeeCap: true }, + }) + + // Find the MENTORING and LIVE_FINAL rounds within this program's competitions + const rounds = await ctx.prisma.round.findMany({ + where: { + competition: { programId: input.programId }, + roundType: { in: ['MENTORING', 'LIVE_FINAL'] }, + }, + select: { id: true, roundType: true }, + orderBy: { sortOrder: 'asc' }, + }) + + const mentoringRound = rounds.find((r) => r.roundType === 'MENTORING') ?? null + const liveFinalRound = rounds.find((r) => r.roundType === 'LIVE_FINAL') ?? null + + if (!mentoringRound) { + return { + liveFinalRoundId: liveFinalRound?.id ?? null, + attendeeCap: program.defaultAttendeeCap, + categories: [], + } + } + + // Load all PRS in the MENTORING round with full project + team data + const states = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: mentoringRound.id }, + select: { + project: { + select: { + id: true, + title: true, + teamName: true, + country: true, + competitionCategory: true, + finalistConfirmation: { select: { status: true } }, + projectRoundStates: { + where: { roundId: liveFinalRound?.id ?? '' }, + select: { projectId: true }, + take: 1, + }, + teamMembers: { + select: { + userId: true, + role: true, + user: { select: { name: true, email: true } }, + }, + }, + }, + }, + }, + orderBy: [{ project: { title: 'asc' } }], + }) + + // Aggregate confirmed/pending counts per category (mirror listCategoryCounts) + const grouped = await ctx.prisma.finalistConfirmation.groupBy({ + by: ['category', 'status'], + where: { project: { programId: input.programId } }, + _count: { _all: true }, + }) + const countsByCategory = new Map() + for (const g of grouped) { + const slot = countsByCategory.get(g.category) ?? { confirmed: 0, pending: 0 } + if (g.status === 'CONFIRMED') slot.confirmed = g._count._all + if (g.status === 'PENDING') slot.pending = g._count._all + countsByCategory.set(g.category, slot) + } + + // Load quotas for this program + const quotas = await ctx.prisma.finalistSlotQuota.findMany({ + where: { programId: input.programId }, + select: { category: true, quota: true }, + }) + const quotaByCategory = new Map(quotas.map((q) => [q.category as string, q.quota])) + + // Group candidates by competitionCategory + const categoryMap = new Map< + string, + { + category: string + quota: number | null + confirmedCount: number + pendingCount: number + candidates: Array<{ + projectId: string + title: string + teamName: string | null + country: string | null + inLiveFinal: boolean + confirmationStatus: string | null + teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }> + }> + } + >() + + for (const s of states) { + const p = s.project + const cat = (p.competitionCategory as string) ?? 'UNKNOWN' + + if (!categoryMap.has(cat)) { + const counts = countsByCategory.get(cat) ?? { confirmed: 0, pending: 0 } + categoryMap.set(cat, { + category: cat, + quota: quotaByCategory.get(cat) ?? null, + confirmedCount: counts.confirmed, + pendingCount: counts.pending, + candidates: [], + }) + } + + const inLiveFinal = p.projectRoundStates.length > 0 + + categoryMap.get(cat)!.candidates.push({ + projectId: p.id, + title: p.title, + teamName: p.teamName, + country: p.country, + inLiveFinal, + confirmationStatus: p.finalistConfirmation?.status ?? null, + teamMembers: p.teamMembers.map((tm) => ({ + userId: tm.userId, + name: tm.user.name, + role: tm.role, + email: tm.user.email, + })), + }) + } + + return { + liveFinalRoundId: liveFinalRound?.id ?? null, + attendeeCap: program.defaultAttendeeCap, + categories: Array.from(categoryMap.values()), + } + }), + /** * Unified finalist enrollment: advances a set of projects into the LIVE_FINAL * round (creates ProjectRoundState, skipDuplicates) AND creates/resets their diff --git a/tests/unit/finalist-enrollment.test.ts b/tests/unit/finalist-enrollment.test.ts index 8e20a7a..1d18fb8 100644 --- a/tests/unit/finalist-enrollment.test.ts +++ b/tests/unit/finalist-enrollment.test.ts @@ -317,3 +317,176 @@ describe('finalist.enrollFinalists', () => { ).rejects.toThrow(/cap/i) }) }) + +// ─── finalist.listEnrollmentCandidates ──────────────────────────────────── + +describe('finalist.listEnrollmentCandidates', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const id of programIds) { + await prisma.finalistSlotQuota.deleteMany({ where: { programId: id } }) + await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } }) + await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } }) + await cleanupTestData(id, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns the STARTUP project under the STARTUP category with inLiveFinal: false and confirmationStatus: null', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const program = await createTestProgram({ + name: `list-candidates-${uid()}`, + defaultAttendeeCap: 3, + }) + programIds.push(program.id) + + const competition = await createTestCompetition(program.id) + const mentoringRound = await createTestRound(competition.id, { + roundType: 'MENTORING', + sortOrder: 60, + }) + const liveFinalRound = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 70, + configJson: { confirmationWindowHours: 24 }, + }) + + const project = await createTestProject(program.id, { + title: 'Candidate Project', + competitionCategory: 'STARTUP', + }) + const lead = await createApplicantUser('LEAD') + const member = await createApplicantUser('MEMBER') + userIds.push(lead.id, member.id) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: member.id, role: 'MEMBER' }, + ], + }) + + // Put project in MENTORING round (this makes it a candidate) + await prisma.projectRoundState.create({ + data: { projectId: project.id, roundId: mentoringRound.id }, + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const result = await caller.listEnrollmentCandidates({ programId: program.id }) + + // Top-level shape + expect(result.liveFinalRoundId).toBe(liveFinalRound.id) + expect(result.attendeeCap).toBe(3) + + // There should be exactly one category entry for STARTUP + const startupCat = result.categories.find((c: { category: string }) => c.category === 'STARTUP') + expect(startupCat).toBeDefined() + expect(startupCat!.quota).toBeNull() // no quota set + expect(startupCat!.confirmedCount).toBe(0) + expect(startupCat!.pendingCount).toBe(0) + + // One candidate + expect(startupCat!.candidates).toHaveLength(1) + const candidate = startupCat!.candidates[0] + expect(candidate.projectId).toBe(project.id) + expect(candidate.title).toBe('Candidate Project') + expect(candidate.inLiveFinal).toBe(false) + expect(candidate.confirmationStatus).toBeNull() + + // Team members listed + expect(candidate.teamMembers).toHaveLength(2) + const memberIds = candidate.teamMembers.map((tm: { userId: string }) => tm.userId) + expect(memberIds).toContain(lead.id) + expect(memberIds).toContain(member.id) + const leadMember = candidate.teamMembers.find((tm: { userId: string }) => tm.userId === lead.id) + expect(leadMember!.role).toBe('LEAD') + expect(leadMember!.email).toBeTruthy() + }) + + it('reflects inLiveFinal: true when the project has a LIVE_FINAL ProjectRoundState', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const program = await createTestProgram({ + name: `list-candidates-enrolled-${uid()}`, + defaultAttendeeCap: 3, + }) + programIds.push(program.id) + + const competition = await createTestCompetition(program.id) + const mentoringRound = await createTestRound(competition.id, { + roundType: 'MENTORING', + sortOrder: 60, + }) + const liveFinalRound = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 70, + }) + + const project = await createTestProject(program.id, { + title: 'Enrolled Candidate', + competitionCategory: 'STARTUP', + }) + const lead = await createApplicantUser('LEAD') + userIds.push(lead.id) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + + // In MENTORING round AND in LIVE_FINAL round + await prisma.projectRoundState.createMany({ + data: [ + { projectId: project.id, roundId: mentoringRound.id }, + { projectId: project.id, roundId: liveFinalRound.id }, + ], + }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const result = await caller.listEnrollmentCandidates({ programId: program.id }) + + const startupCat = result.categories.find((c: { category: string }) => c.category === 'STARTUP') + expect(startupCat).toBeDefined() + const candidate = startupCat!.candidates.find((c: { projectId: string }) => c.projectId === project.id) + expect(candidate).toBeDefined() + expect(candidate!.inLiveFinal).toBe(true) + }) + + it('returns empty categories and null liveFinalRoundId when no MENTORING round exists', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const program = await createTestProgram({ name: `list-candidates-empty-${uid()}` }) + programIds.push(program.id) + + // Competition with only an EVALUATION round (no MENTORING) + const competition = await createTestCompetition(program.id) + await createTestRound(competition.id, { roundType: 'EVALUATION', sortOrder: 10 }) + + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const result = await caller.listEnrollmentCandidates({ programId: program.id }) + + expect(result.categories).toHaveLength(0) + expect(result.liveFinalRoundId).toBeNull() + }) +})