From e8d0bb050f9e4ec01a5a75c2e7c28c21a44a0b7a Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 May 2026 20:02:35 +0200 Subject: [PATCH] fix(finalization): skip MENTORING rounds in advancement display copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mentoring round is opt-in (eligibility: requested_only) and only a subset of advancing teams enter it; the rest auto-pass through. Showing it as the "next round" in the finalization summary and advancement emails was misleading since Grand Finale is the shared destination for all advancing teams. Routing is unchanged — targetRoundId still points to the next round by sortOrder (may be MENTORING) so opt-in handling is preserved. Only the user-facing label skips MENTORING. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/roundEngine.ts | 14 +++++-- src/server/services/round-finalization.ts | 46 +++++++++++++++++------ 2 files changed, 45 insertions(+), 15 deletions(-) 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) => {