feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user