fix(finalization): skip MENTORING rounds in advancement display copy
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m10s

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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-05 20:02:35 +02:00
parent 6e36704bb1
commit e8d0bb050f
2 changed files with 45 additions and 15 deletions

View File

@@ -15,6 +15,7 @@ import {
processRoundClose, processRoundClose,
getFinalizationSummary, getFinalizationSummary,
confirmFinalization, confirmFinalization,
findDisplayNextRound,
} from '../services/round-finalization' } from '../services/round-finalization'
import { import {
getAdvancementNotificationTemplate, getAdvancementNotificationTemplate,
@@ -382,14 +383,19 @@ export const roundEngineRouter = router({
select: { select: {
name: true, name: true,
competition: { 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 rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === input.roundId) // Skip MENTORING rounds for display — those are opt-in and not the
const nextRound = rounds[currentIdx + 1] // shared destination for all advancing teams.
const toRoundName = nextRound?.name ?? 'Next Round' const toRoundName = findDisplayNextRound(rounds, input.roundId)?.name ?? 'Next Round'
const passedCount = await ctx.prisma.projectRoundState.count({ const passedCount = await ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, proposedOutcome: 'PASSED' }, where: { roundId: input.roundId, proposedOutcome: 'PASSED' },

View File

@@ -400,6 +400,30 @@ export async function processRoundClose(
return { processed } 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<T extends { id: string; name: string; roundType?: RoundType }>(
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 ───────────────────────────────────────────────── // ─── getFinalizationSummary ─────────────────────────────────────────────────
export async function getFinalizationSummary( export async function getFinalizationSummary(
@@ -412,7 +436,7 @@ export async function getFinalizationSummary(
competition: { competition: {
select: { select: {
rounds: { rounds: {
select: { id: true, name: true, sortOrder: true }, select: { id: true, name: true, sortOrder: true, roundType: true },
orderBy: { sortOrder: 'asc' as const }, orderBy: { sortOrder: 'asc' as const },
}, },
}, },
@@ -427,12 +451,10 @@ export async function getFinalizationSummary(
// Get config for category targets // Get config for category targets
const config = (round.configJson as Record<string, unknown>) ?? {} const config = (round.configJson as Record<string, unknown>) ?? {}
// 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 rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId) const nextRound = findDisplayNextRound(rounds, roundId)
const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1
? rounds[currentIdx + 1]
: null
// Get all project states with project details // Get all project states with project details
const projectStates = await prisma.projectRoundState.findMany({ const projectStates = await prisma.projectRoundState.findMany({
@@ -613,7 +635,7 @@ export async function confirmFinalization(
select: { select: {
id: true, id: true,
rounds: { rounds: {
select: { id: true, name: true, sortOrder: true }, select: { id: true, name: true, sortOrder: true, roundType: true },
orderBy: { sortOrder: 'asc' as const }, orderBy: { sortOrder: 'asc' as const },
}, },
}, },
@@ -634,7 +656,8 @@ export async function confirmFinalization(
throw new Error('Cannot finalize: grace period is still active') 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 rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId) const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
const targetRoundId = options.targetRoundId const targetRoundId = options.targetRoundId
@@ -642,9 +665,10 @@ export async function confirmFinalization(
? rounds[currentIdx + 1].id ? rounds[currentIdx + 1].id
: undefined) : undefined)
const targetRoundName = targetRoundId // Display name for emails / notifications: skip MENTORING rounds so the
? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round' // copy reflects the shared destination (e.g. "Grand Finale") rather than
: 'Next Round' // the opt-in mentoring step.
const targetRoundName = findDisplayNextRound(rounds, roundId)?.name ?? 'Next Round'
// Execute finalization in a transaction // Execute finalization in a transaction
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {