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:
Matt
2026-06-04 15:29:26 +02:00
parent 375aeb08af
commit e80710487c
2 changed files with 320 additions and 0 deletions

View File

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