feat(finalist): listEnrollmentCandidates query for enrollment UI
Returns mentoring-round candidates grouped by category with status, team members, quota and confirmed/pending counts; inLiveFinal flag and attendeeCap for the enrollment UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, { confirmed: number; pending: number }>()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user