diff --git a/src/server/routers/roundEngine.ts b/src/server/routers/roundEngine.ts index cc33c4b..09bb628 100644 --- a/src/server/routers/roundEngine.ts +++ b/src/server/routers/roundEngine.ts @@ -15,6 +15,7 @@ import { processRoundClose, getFinalizationSummary, confirmFinalization, + findDisplayNextRound, } from '../services/round-finalization' import { getAdvancementNotificationTemplate, @@ -382,14 +383,19 @@ export const roundEngineRouter = router({ select: { name: true, competition: { - select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } }, + select: { + rounds: { + select: { id: true, name: true, sortOrder: true, roundType: true }, + orderBy: { sortOrder: 'asc' }, + }, + }, }, }, }) const rounds = round.competition.rounds - const currentIdx = rounds.findIndex((r) => r.id === input.roundId) - const nextRound = rounds[currentIdx + 1] - const toRoundName = nextRound?.name ?? 'Next Round' + // Skip MENTORING rounds for display — those are opt-in and not the + // shared destination for all advancing teams. + const toRoundName = findDisplayNextRound(rounds, input.roundId)?.name ?? 'Next Round' const passedCount = await ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, proposedOutcome: 'PASSED' }, diff --git a/src/server/services/round-finalization.ts b/src/server/services/round-finalization.ts index 76a9f67..dd44f98 100644 --- a/src/server/services/round-finalization.ts +++ b/src/server/services/round-finalization.ts @@ -400,6 +400,30 @@ export async function processRoundClose( return { processed } } +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Pick the round to *display* as the destination after finalization. + * + * Mentoring rounds are opt-in (eligibility: "requested_only") — only teams that + * requested mentorship enter, and the rest auto-pass through to the round after. + * For user-facing copy ("X teams will advance to ..."), we therefore skip past + * MENTORING rounds and report the next "proper" round (e.g. Grand Finale) + * that all advancing teams ultimately share. The actual targetRoundId used by + * the round engine for state transitions is unaffected. + */ +export function findDisplayNextRound( + rounds: readonly T[], + currentRoundId: string, +): T | null { + const currentIdx = rounds.findIndex((r) => r.id === currentRoundId) + if (currentIdx < 0) return null + for (let i = currentIdx + 1; i < rounds.length; i++) { + if (rounds[i].roundType !== 'MENTORING') return rounds[i] + } + return null +} + // ─── getFinalizationSummary ───────────────────────────────────────────────── export async function getFinalizationSummary( @@ -412,7 +436,7 @@ export async function getFinalizationSummary( competition: { select: { rounds: { - select: { id: true, name: true, sortOrder: true }, + select: { id: true, name: true, sortOrder: true, roundType: true }, orderBy: { sortOrder: 'asc' as const }, }, }, @@ -427,12 +451,10 @@ export async function getFinalizationSummary( // Get config for category targets const config = (round.configJson as Record) ?? {} - // Find next round + // Find next round (skip MENTORING rounds — those are opt-in and not the + // shared destination for all advancing teams) const rounds = round.competition.rounds - const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId) - const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1 - ? rounds[currentIdx + 1] - : null + const nextRound = findDisplayNextRound(rounds, roundId) // Get all project states with project details const projectStates = await prisma.projectRoundState.findMany({ @@ -613,7 +635,7 @@ export async function confirmFinalization( select: { id: true, rounds: { - select: { id: true, name: true, sortOrder: true }, + select: { id: true, name: true, sortOrder: true, roundType: true }, orderBy: { sortOrder: 'asc' as const }, }, }, @@ -634,7 +656,8 @@ export async function confirmFinalization( throw new Error('Cannot finalize: grace period is still active') } - // Determine target round + // Determine target round for routing: the actual next round by sortOrder + // (may be a MENTORING round — opt-in teams stay there, others auto-pass through). const rounds = round.competition.rounds const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId) const targetRoundId = options.targetRoundId @@ -642,9 +665,10 @@ export async function confirmFinalization( ? rounds[currentIdx + 1].id : undefined) - const targetRoundName = targetRoundId - ? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round' - : 'Next Round' + // Display name for emails / notifications: skip MENTORING rounds so the + // copy reflects the shared destination (e.g. "Grand Finale") rather than + // the opt-in mentoring step. + const targetRoundName = findDisplayNextRound(rounds, roundId)?.name ?? 'Next Round' // Execute finalization in a transaction const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {