feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -15,6 +15,7 @@ import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/clien
import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -55,11 +56,11 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
}
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
PASSED: ['COMPLETED', 'WITHDRAWN'],
PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
COMPLETED: ['PASSED', 'REJECTED'],
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
REJECTED: ['PENDING'], // re-include
COMPLETED: [], // terminal
WITHDRAWN: ['PENDING'], // re-include
}
@@ -174,13 +175,44 @@ export async function activateRound(
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (result.transitionedCount > 0) {
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
// Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
// auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
if (round.roundType === 'MENTORING') {
try {
const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
const pendingProjects = await prisma.projectRoundState.findMany({
where: { roundId, state: 'PENDING' },
select: { id: true, projectId: true, metadataJson: true },
})
let passedCount = 0
for (const prs of pendingProjects) {
const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
// Only pass-through projects that haven't requested mentoring
if (!meta.mentoringRequested) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { state: 'PASSED' },
})
passedCount++
}
}
if (passedCount > 0) {
console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
}
}
} catch (mentoringError) {
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
}
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -311,6 +343,26 @@ export async function closeRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
// Grace period / immediate finalization processing
try {
const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
const gracePeriodHours = (config.gracePeriodHours as number) ?? 0
if (gracePeriodHours > 0) {
const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
await prisma.round.update({
where: { id: roundId },
data: { gracePeriodEndsAt },
})
console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
} else {
await processRoundClose(roundId, actorId, prisma)
console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
}
} catch (processError) {
console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -559,10 +611,10 @@ export async function transitionProject(
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_ACTIVE') {
if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
return {
success: false,
errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`],
errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
}
}
@@ -858,12 +910,17 @@ export async function checkRequirementsAndTransition(
return { transitioned: false }
}
// All requirements met — transition to PASSED
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
// If PENDING, first transition to IN_PROGRESS so the state machine path is valid
if (currentState.state === 'PENDING') {
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
}
// All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'PASSED' }
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
return { transitioned: true, newState: 'COMPLETED' }
}
return { transitioned: false }
@@ -894,14 +951,85 @@ export async function batchCheckRequirementsAndTransition(
}
if (transitioned.length > 0) {
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
}
return { transitionedCount: transitioned.length, projectIds: transitioned }
}
// ─── Auto-Transition Hooks ──────────────────────────────────────────────────
/**
* Trigger PENDING → IN_PROGRESS when a project has activity.
* Non-fatal: if the project is not PENDING, this is a no-op.
*/
export async function triggerInProgressOnActivity(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<void> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'PENDING') return
const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
}
} catch (error) {
console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
}
}
/**
* Check if all jury assignments for a project in an evaluation round are completed.
* If yes, transition from IN_PROGRESS → COMPLETED.
*/
export async function checkEvaluationCompletionAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitioned: boolean }> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }
// Check all assignments for this project in this round
const assignments = await prisma.assignment.findMany({
where: { projectId, roundId },
select: { isCompleted: true },
})
if (assignments.length === 0) return { transitioned: false }
const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
if (!allCompleted) return { transitioned: false }
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${assignments.length} evaluations done)`)
return { transitioned: true }
}
return { transitioned: false }
} catch (error) {
console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
return { transitioned: false }
}
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
export function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
}