/** * Round Finalization Service * * Handles the post-close lifecycle of a round: * - processRoundClose: auto-sets project states after a round closes * - getFinalizationSummary: aggregates data for the finalization review UI * - confirmFinalization: single transaction to apply outcomes, advance projects, send emails */ import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client' import { transitionProject, isTerminalState } from './round-engine' import { logAudit } from '@/server/utils/audit' import { sendStyledNotificationEmail, getRejectionNotificationTemplate, } from '@/lib/email' import { createBulkNotifications } from '../services/in-app-notification' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' // ─── Types ────────────────────────────────────────────────────────────────── export type FinalizationSummary = { roundId: string roundName: string roundType: RoundType isGracePeriodActive: boolean gracePeriodEndsAt: Date | null isFinalized: boolean finalizedAt: Date | null stats: { pending: number inProgress: number completed: number passed: number rejected: number withdrawn: number } projects: Array<{ id: string title: string teamName: string | null category: string | null country: string | null currentState: ProjectRoundStateValue proposedOutcome: ProjectRoundStateValue | null evaluationScore?: number | null rankPosition?: number | null }> categoryTargets: { startupTarget: number | null conceptTarget: number | null startupProposed: number conceptProposed: number } nextRound: { id: string; name: string } | null accountStats: { needsInvite: number hasAccount: number } } export type ConfirmFinalizationResult = { advanced: number rejected: number emailsSent: number emailsFailed: number } // ─── processRoundClose ────────────────────────────────────────────────────── /** * Process project states after a round closes. * Auto-transitions projects to COMPLETED/REJECTED and sets proposedOutcome defaults. * Called immediately on close (if no grace period) or after grace period expires. */ export async function processRoundClose( roundId: string, actorId: string, prisma: PrismaClient | any, ): Promise<{ processed: number }> { const round = await prisma.round.findUnique({ where: { id: roundId }, include: { competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const }, }, }, }, }, }) if (!round) throw new Error(`Round ${roundId} not found`) const projectStates = await prisma.projectRoundState.findMany({ where: { roundId }, include: { project: { select: { id: true, competitionCategory: true, files: { where: { roundId }, select: { id: true, requirementId: true, submissionFileRequirementId: true } }, assignments: { where: { roundId }, select: { isCompleted: true } }, filteringResults: { where: { roundId }, select: { outcome: true, finalOutcome: true } }, }, }, }, }) let processed = 0 // Pre-compute pass set for EVALUATION rounds using ranking scores + config. // Respects admin drag-reorder overrides stored in reordersJson. let evaluationPassSet: Set | null = null if ((round.roundType as RoundType) === 'EVALUATION') { evaluationPassSet = new Set() const snapshot = await prisma.rankingSnapshot.findFirst({ where: { roundId }, orderBy: { createdAt: 'desc' as const }, select: { startupRankingJson: true, conceptRankingJson: true, reordersJson: true }, }) if (snapshot) { const config = (round.configJson as Record) ?? {} const advanceMode = (config.advanceMode as string) || 'count' const advanceScoreThreshold = (config.advanceScoreThreshold as number) ?? 6 const startupAdvanceCount = (config.startupAdvanceCount as number) ?? 0 const conceptAdvanceCount = (config.conceptAdvanceCount as number) ?? 0 type RankEntry = { projectId: string; avgGlobalScore: number | null; rank: number } const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[] const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[] // Apply admin drag-reorder overrides (reordersJson is append-only, latest per category wins) type ReorderEvent = { category: 'STARTUP' | 'BUSINESS_CONCEPT'; orderedProjectIds: string[] } const reorders = (snapshot.reordersJson as ReorderEvent[] | null) ?? [] const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP') const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT') // Build effective order: if admin reordered, use that; otherwise use computed rank order const effectiveStartup = latestStartupReorder ? latestStartupReorder.orderedProjectIds : [...startupRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId) const effectiveConcept = latestConceptReorder ? latestConceptReorder.orderedProjectIds : [...conceptRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId) // Build score lookup for threshold mode const scoreMap = new Map() for (const r of [...startupRanked, ...conceptRanked]) { if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore) } if (advanceMode === 'threshold') { for (const id of [...effectiveStartup, ...effectiveConcept]) { const score = scoreMap.get(id) if (score != null && score >= advanceScoreThreshold) { evaluationPassSet.add(id) } } } else { // 'count' mode — top N per category using effective (possibly reordered) order for (let i = 0; i < Math.min(startupAdvanceCount, effectiveStartup.length); i++) { evaluationPassSet.add(effectiveStartup[i]) } for (let i = 0; i < Math.min(conceptAdvanceCount, effectiveConcept.length); i++) { evaluationPassSet.add(effectiveConcept[i]) } } } } for (const prs of projectStates) { // Skip already-terminal states if (isTerminalState(prs.state)) { // Set proposed outcome to match current state for display if (!prs.proposedOutcome) { await prisma.projectRoundState.update({ where: { id: prs.id }, data: { proposedOutcome: prs.state }, }) } processed++ continue } let targetState: ProjectRoundStateValue = prs.state let proposedOutcome: ProjectRoundStateValue = 'PASSED' switch (round.roundType as RoundType) { case 'INTAKE': case 'SUBMISSION': { // Projects with activity → COMPLETED, purely PENDING → REJECTED if (prs.state === 'PENDING') { targetState = 'REJECTED' as ProjectRoundStateValue proposedOutcome = 'REJECTED' as ProjectRoundStateValue } else if (prs.state === 'IN_PROGRESS' || prs.state === 'COMPLETED') { if (prs.state === 'IN_PROGRESS') targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = 'PASSED' as ProjectRoundStateValue } break } case 'EVALUATION': { // Use ranking scores to determine pass/reject const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted) const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue } else if (prs.state === 'PENDING') { targetState = 'REJECTED' as ProjectRoundStateValue proposedOutcome = 'REJECTED' as ProjectRoundStateValue } else if (prs.state === 'COMPLETED') { proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue } break } case 'FILTERING': { // Use FilteringResult to determine outcome for each project const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined const effectiveOutcome = fr?.finalOutcome || fr?.outcome const filterPassed = effectiveOutcome !== 'FILTERED_OUT' if (prs.state === 'COMPLETED') { proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue } else if (prs.state === 'IN_PROGRESS') { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue } else if (prs.state === 'PENDING') { // PENDING projects in filtering: check FilteringResult if (fr) { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue } else { // No filtering result at all → reject targetState = 'REJECTED' as ProjectRoundStateValue proposedOutcome = 'REJECTED' as ProjectRoundStateValue } } break } case 'MENTORING': { // Projects already PASSED (pass-through) stay PASSED if (prs.state === 'PASSED') { proposedOutcome = 'PASSED' as ProjectRoundStateValue } else if (prs.state === 'IN_PROGRESS') { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = 'PASSED' as ProjectRoundStateValue } else if (prs.state === 'COMPLETED') { proposedOutcome = 'PASSED' as ProjectRoundStateValue } else if (prs.state === 'PENDING') { // Pending = never requested mentoring, pass through proposedOutcome = 'PASSED' as ProjectRoundStateValue targetState = 'COMPLETED' as ProjectRoundStateValue } break } case 'LIVE_FINAL': { // All presented projects → COMPLETED if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = 'PASSED' as ProjectRoundStateValue } else if (prs.state === 'COMPLETED') { proposedOutcome = 'PASSED' as ProjectRoundStateValue } break } case 'DELIBERATION': { // All voted projects → COMPLETED if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') { targetState = 'COMPLETED' as ProjectRoundStateValue proposedOutcome = 'PASSED' as ProjectRoundStateValue } else if (prs.state === 'COMPLETED') { proposedOutcome = 'PASSED' as ProjectRoundStateValue } break } } // Transition project if needed (admin override for non-standard paths) if (targetState !== prs.state && !isTerminalState(prs.state)) { // Need to handle multi-step transitions if (prs.state === 'PENDING' && targetState === 'COMPLETED') { await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true }) await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true }) } else if (prs.state === 'PENDING' && targetState === 'REJECTED') { await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true }) } else { await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true }) } } // Set proposed outcome await prisma.projectRoundState.update({ where: { id: prs.id }, data: { proposedOutcome }, }) processed++ } return { processed } } // ─── getFinalizationSummary ───────────────────────────────────────────────── export async function getFinalizationSummary( roundId: string, prisma: PrismaClient | any, ): Promise { const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, include: { competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const }, }, }, }, }, }) const now = new Date() const isGracePeriodActive = !!(round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now && !round.finalizedAt) const isFinalized = !!round.finalizedAt // Get config for category targets const config = (round.configJson as Record) ?? {} // Find next round 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 // Get all project states with project details const projectStates = await prisma.projectRoundState.findMany({ where: { roundId }, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, }, }, }, orderBy: { createdAt: 'asc' as const }, }) // Compute stats const stats = { pending: 0, inProgress: 0, completed: 0, passed: 0, rejected: 0, withdrawn: 0 } for (const prs of projectStates) { switch (prs.state) { case 'PENDING': stats.pending++; break case 'IN_PROGRESS': stats.inProgress++; break case 'COMPLETED': stats.completed++; break case 'PASSED': stats.passed++; break case 'REJECTED': stats.rejected++; break case 'WITHDRAWN': stats.withdrawn++; break } } // Get evaluation scores if this is an evaluation round let scoreMap = new Map() let rankMap = new Map() if (round.roundType === 'EVALUATION') { // Get latest ranking snapshot (per-category fields) const snapshot = await prisma.rankingSnapshot.findFirst({ where: { roundId }, orderBy: { createdAt: 'desc' as const }, select: { startupRankingJson: true, conceptRankingJson: true }, }) if (snapshot) { type RankEntry = { projectId: string; avgGlobalScore?: number; compositeScore?: number; rank?: number } const allRanked = [ ...((snapshot.startupRankingJson ?? []) as RankEntry[]), ...((snapshot.conceptRankingJson ?? []) as RankEntry[]), ] for (const r of allRanked) { if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore) else if (r.compositeScore != null) scoreMap.set(r.projectId, r.compositeScore) if (r.rank != null) rankMap.set(r.projectId, r.rank) } } } // Build project list const projects = projectStates.map((prs: any) => ({ id: prs.project.id, title: prs.project.title, teamName: prs.project.teamName, category: prs.project.competitionCategory, country: prs.project.country, currentState: prs.state as ProjectRoundStateValue, proposedOutcome: prs.proposedOutcome as ProjectRoundStateValue | null, evaluationScore: scoreMap.get(prs.project.id) ?? null, rankPosition: rankMap.get(prs.project.id) ?? null, })) // Category target progress const startupTarget = (config.startupAdvanceCount as number | undefined) ?? null const conceptTarget = (config.conceptAdvanceCount as number | undefined) ?? null let startupProposed = 0 let conceptProposed = 0 for (const p of projects) { if (p.proposedOutcome === 'PASSED') { if (p.category === 'STARTUP') startupProposed++ else if (p.category === 'BUSINESS_CONCEPT') conceptProposed++ } } // Account stats: count how many advancing projects need invite vs already have accounts let needsInvite = 0 let hasAccount = 0 const passedProjectIds = projects.filter((p: { proposedOutcome: string | null }) => p.proposedOutcome === 'PASSED').map((p: { id: string }) => p.id) if (passedProjectIds.length > 0) { const passedProjects = await prisma.project.findMany({ where: { id: { in: passedProjectIds } }, select: { id: true, submittedBy: { select: { passwordHash: true } }, teamMembers: { select: { user: { select: { passwordHash: true } } } }, }, }) for (const p of passedProjects) { // Check team members first, then submittedBy const users = p.teamMembers.length > 0 ? p.teamMembers.map((tm: any) => tm.user) : p.submittedBy ? [p.submittedBy] : [] const anyHasPassword = users.some((u: any) => !!u.passwordHash) if (anyHasPassword) hasAccount++ else needsInvite++ } } return { roundId, roundName: round.name, roundType: round.roundType, isGracePeriodActive, gracePeriodEndsAt: round.gracePeriodEndsAt, isFinalized, finalizedAt: round.finalizedAt, stats, projects, categoryTargets: { startupTarget, conceptTarget, startupProposed, conceptProposed, }, nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null, accountStats: { needsInvite, hasAccount }, } } // ─── confirmFinalization ──────────────────────────────────────────────────── export async function confirmFinalization( roundId: string, options: { targetRoundId?: string advancementMessage?: string rejectionMessage?: string }, actorId: string, prisma: PrismaClient | any, ): Promise { // Validate: round is CLOSED, not already finalized, grace period expired const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, include: { competition: { select: { id: true, rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const }, }, }, }, }, }) if (round.status !== 'ROUND_CLOSED') { throw new Error(`Round must be ROUND_CLOSED to finalize, got ${round.status}`) } if (round.finalizedAt) { throw new Error('Round is already finalized') } const now = new Date() if (round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now) { throw new Error('Cannot finalize: grace period is still active') } // Determine target round const rounds = round.competition.rounds const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId) const targetRoundId = options.targetRoundId ?? (currentIdx >= 0 && currentIdx < rounds.length - 1 ? rounds[currentIdx + 1].id : undefined) const targetRoundName = targetRoundId ? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round' : 'Next Round' // Execute finalization in a transaction const result = await prisma.$transaction(async (tx: any) => { const projectStates = await tx.projectRoundState.findMany({ where: { roundId, proposedOutcome: { not: null } }, include: { project: { select: { id: true, title: true, status: true, }, }, }, }) let advanced = 0 let rejected = 0 for (const prs of projectStates) { const proposed = prs.proposedOutcome as ProjectRoundStateValue // Skip if already in the proposed state if (prs.state === proposed) { if (proposed === 'PASSED') advanced++ else if (proposed === 'REJECTED') rejected++ continue } // Transition to proposed outcome if (proposed === 'PASSED' || proposed === 'REJECTED') { // Ensure we're in COMPLETED before transitioning to PASSED/REJECTED if (prs.state !== 'COMPLETED' && prs.state !== 'PASSED' && prs.state !== 'REJECTED') { // Force through intermediate states if (prs.state === 'PENDING') { await tx.projectRoundState.update({ where: { id: prs.id }, data: { state: 'IN_PROGRESS' }, }) } if (prs.state === 'PENDING' || prs.state === 'IN_PROGRESS') { await tx.projectRoundState.update({ where: { id: prs.id }, data: { state: 'COMPLETED' }, }) } } // Now transition to final state await tx.projectRoundState.update({ where: { id: prs.id }, data: { state: proposed, exitedAt: now, }, }) if (proposed === 'PASSED') { advanced++ // Create ProjectRoundState in target round (if exists) if (targetRoundId) { await tx.projectRoundState.upsert({ where: { projectId_roundId: { projectId: prs.projectId, roundId: targetRoundId, }, }, create: { projectId: prs.projectId, roundId: targetRoundId, state: 'PENDING', enteredAt: now, }, update: {}, // skip if already exists }) } // Update Project.status to ASSIGNED await tx.project.update({ where: { id: prs.projectId }, data: { status: 'ASSIGNED' }, }) // Create ProjectStatusHistory await tx.projectStatusHistory.create({ data: { projectId: prs.projectId, status: 'ASSIGNED', changedBy: actorId, }, }) } else { rejected++ } // Audit log per project await tx.decisionAuditLog.create({ data: { eventType: 'finalization.project_outcome', entityType: 'ProjectRoundState', entityId: prs.id, actorId, detailsJson: { projectId: prs.projectId, roundId, previousState: prs.state, outcome: proposed, targetRoundId: proposed === 'PASSED' ? targetRoundId : null, } as Prisma.InputJsonValue, snapshotJson: { timestamp: now.toISOString(), emittedBy: 'round-finalization', }, }, }) } } // Mark round as finalized await tx.round.update({ where: { id: roundId }, data: { finalizedAt: now, finalizedBy: actorId, }, }) // Finalization audit await tx.decisionAuditLog.create({ data: { eventType: 'round.finalized', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, advanced, rejected, targetRoundId, hasCustomAdvancementMessage: !!options.advancementMessage, hasCustomRejectionMessage: !!options.rejectionMessage, } as Prisma.InputJsonValue, snapshotJson: { timestamp: now.toISOString(), emittedBy: 'round-finalization', }, }, }) return { advanced, rejected } }) // Send emails outside transaction (non-fatal) let emailsSent = 0 let emailsFailed = 0 try { // Get all projects that were finalized const finalizedStates = await prisma.projectRoundState.findMany({ where: { roundId, state: { in: ['PASSED', 'REJECTED'] } }, include: { project: { select: { id: true, title: true, submittedByEmail: true, submittedByUserId: true, submittedBy: { select: { id: true, email: true, name: true, passwordHash: true } }, teamMembers: { select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } }, }, }, }, }, }) // Pre-generate invite tokens for passwordless users on advancing projects const inviteTokenMap = new Map() // userId → token const expiryMs = await getInviteExpiryMs(prisma) for (const prs of finalizedStates) { if (prs.state !== 'PASSED') continue const users = prs.project.teamMembers.length > 0 ? prs.project.teamMembers.map((tm: any) => tm.user) : prs.project.submittedBy ? [prs.project.submittedBy] : [] for (const user of users) { if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) { const token = generateInviteToken() inviteTokenMap.set(user.id, token) await prisma.user.update({ where: { id: user.id }, data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) } } } const advancedUserIds = new Set() const rejectedUserIds = new Set() for (const prs of finalizedStates) { type Recipient = { email: string; name: string | null; userId: string | null } const recipients: Recipient[] = [] for (const tm of prs.project.teamMembers) { if (tm.user.email) { recipients.push({ email: tm.user.email, name: tm.user.name, userId: tm.user.id }) if (prs.state === 'PASSED') advancedUserIds.add(tm.user.id) else rejectedUserIds.add(tm.user.id) } } if (recipients.length === 0 && prs.project.submittedBy?.email) { recipients.push({ email: prs.project.submittedBy.email, name: prs.project.submittedBy.name, userId: prs.project.submittedBy.id, }) if (prs.state === 'PASSED') advancedUserIds.add(prs.project.submittedBy.id) else rejectedUserIds.add(prs.project.submittedBy.id) } else if (recipients.length === 0 && prs.project.submittedByEmail) { recipients.push({ email: prs.project.submittedByEmail, name: null, userId: null }) } for (const recipient of recipients) { try { if (prs.state === 'PASSED') { // Build account creation URL for passwordless users const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined const accountUrl = token ? `/accept-invite?token=${token}` : undefined await sendStyledNotificationEmail( recipient.email, recipient.name || '', 'ADVANCEMENT_NOTIFICATION', { title: 'Your project has advanced!', message: '', linkUrl: accountUrl || '/applicant', metadata: { projectName: prs.project.title, fromRoundName: round.name, toRoundName: targetRoundName, customMessage: options.advancementMessage || undefined, accountUrl, }, }, ) } else { await sendStyledNotificationEmail( recipient.email, recipient.name || '', 'REJECTION_NOTIFICATION', { title: `Update on your application: "${prs.project.title}"`, message: '', metadata: { projectName: prs.project.title, roundName: round.name, customMessage: options.rejectionMessage || undefined, }, }, ) } emailsSent++ } catch (err) { console.error(`[Finalization] Email failed for ${recipient.email}:`, err) emailsFailed++ } } } // Create in-app notifications if (advancedUserIds.size > 0) { void createBulkNotifications({ userIds: [...advancedUserIds], type: 'project_advanced', title: 'Your project has advanced!', message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`, linkUrl: '/applicant', linkLabel: 'View Dashboard', icon: 'Trophy', priority: 'high', }) } if (rejectedUserIds.size > 0) { void createBulkNotifications({ userIds: [...rejectedUserIds], type: 'project_rejected', title: 'Competition Update', message: `Your project did not advance past "${round.name}".`, linkUrl: '/applicant', linkLabel: 'View Dashboard', icon: 'Info', priority: 'normal', }) } } catch (emailError) { console.error('[Finalization] Email batch failed (non-fatal):', emailError) } // External audit log await logAudit({ userId: actorId, action: 'ROUND_FINALIZED', entityType: 'Round', entityId: roundId, detailsJson: { roundName: round.name, advanced: result.advanced, rejected: result.rejected, emailsSent, emailsFailed, }, }) return { advanced: result.advanced, rejected: result.rejected, emailsSent, emailsFailed, } }