/** * Round Engine Service * * State machine for round lifecycle transitions, operating on Round + * ProjectRoundState. Parallels stage-engine.ts but for the Competition/Round * architecture. * * Key invariants: * - Round transitions follow: ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED * - Project transitions within an active round only * - All mutations are transactional with dual audit trail */ import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/client' import { logAudit } from '@/server/utils/audit' import { safeValidateRoundConfig } from '@/types/competition-configs' import { expireIntentsForRound } from './assignment-intent' // ─── Types ────────────────────────────────────────────────────────────────── export type RoundTransitionResult = { success: boolean round?: { id: string; status: string } errors?: string[] } export type ProjectRoundTransitionResult = { success: boolean projectRoundState?: { id: string projectId: string roundId: string state: ProjectRoundStateValue } errors?: string[] } export type BatchProjectTransitionResult = { succeeded: string[] failed: Array<{ projectId: string; errors: string[] }> total: number } // ─── Constants ────────────────────────────────────────────────────────────── const BATCH_SIZE = 50 // ─── Valid Transition Maps ────────────────────────────────────────────────── const VALID_ROUND_TRANSITIONS: Record = { ROUND_DRAFT: ['ROUND_ACTIVE'], ROUND_ACTIVE: ['ROUND_CLOSED'], ROUND_CLOSED: ['ROUND_ARCHIVED'], ROUND_ARCHIVED: [], } // ─── Round-Level Transitions ──────────────────────────────────────────────── /** * Activate a round: ROUND_DRAFT → ROUND_ACTIVE * Guards: configJson is valid, competition is not ARCHIVED * Side effects: expire pending intents from previous round (if any) */ export async function activateRound( roundId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId }, include: { competition: true }, }) if (!round) { return { success: false, errors: [`Round ${roundId} not found`] } } // Check valid transition if (round.status !== 'ROUND_DRAFT') { return { success: false, errors: [`Cannot activate round: current status is ${round.status}, expected ROUND_DRAFT`], } } // Guard: competition must not be ARCHIVED if (round.competition.status === 'ARCHIVED') { return { success: false, errors: ['Cannot activate round: competition is ARCHIVED'], } } // Guard: configJson must be valid if (round.configJson) { const validation = safeValidateRoundConfig( round.roundType, round.configJson as Record, ) if (!validation.success) { return { success: false, errors: [`Invalid round config: ${validation.error.message}`], } } } const updated = await prisma.$transaction(async (tx: any) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ACTIVE' }, }) // Dual audit trail await tx.decisionAuditLog.create({ data: { eventType: 'round.activated', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, roundType: round.roundType, competitionId: round.competitionId, previousStatus: 'ROUND_DRAFT', }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'ROUND_ACTIVATE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name, roundType: round.roundType }, }) return result }) return { success: true, round: { id: updated.id, status: updated.status }, } } catch (error) { console.error('[RoundEngine] activateRound failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error during round activation'], } } } /** * Close a round: ROUND_ACTIVE → ROUND_CLOSED * Guards: all submission windows closed (if submission/mentoring round) * Side effects: expire all INTENT_PENDING for this round */ export async function closeRound( roundId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId }, include: { submissionWindow: true }, }) if (!round) { return { success: false, errors: [`Round ${roundId} not found`] } } if (round.status !== 'ROUND_ACTIVE') { return { success: false, errors: [`Cannot close round: current status is ${round.status}, expected ROUND_ACTIVE`], } } // Guard: submission window must be closed/locked for submission/mentoring rounds if ( (round.roundType === 'SUBMISSION' || round.roundType === 'MENTORING') && round.submissionWindow ) { const sw = round.submissionWindow if (sw.windowCloseAt && new Date() < sw.windowCloseAt && !sw.isLocked) { return { success: false, errors: ['Cannot close round: linked submission window is still open'], } } } const updated = await prisma.$transaction(async (tx: any) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_CLOSED' }, }) // Expire pending intents await expireIntentsForRound(roundId, actorId) await tx.decisionAuditLog.create({ data: { eventType: 'round.closed', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, roundType: round.roundType, previousStatus: 'ROUND_ACTIVE', }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'ROUND_CLOSE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name, roundType: round.roundType }, }) return result }) return { success: true, round: { id: updated.id, status: updated.status }, } } catch (error) { console.error('[RoundEngine] closeRound failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error during round close'], } } } /** * Archive a round: ROUND_CLOSED → ROUND_ARCHIVED * No guards. */ export async function archiveRound( roundId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId } }) if (!round) { return { success: false, errors: [`Round ${roundId} not found`] } } if (round.status !== 'ROUND_CLOSED') { return { success: false, errors: [`Cannot archive round: current status is ${round.status}, expected ROUND_CLOSED`], } } const updated = await prisma.$transaction(async (tx: any) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ARCHIVED' }, }) await tx.decisionAuditLog.create({ data: { eventType: 'round.archived', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, previousStatus: 'ROUND_CLOSED', }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'ROUND_ARCHIVE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name }, }) return result }) return { success: true, round: { id: updated.id, status: updated.status }, } } catch (error) { console.error('[RoundEngine] archiveRound failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error during round archive'], } } } // ─── Project-Level Transitions ────────────────────────────────────────────── /** * Transition a project within a round. * Upserts ProjectRoundState: create if not exists, update if exists. * Validate: round must be ROUND_ACTIVE. * Dual audit trail (DecisionAuditLog + logAudit). */ export async function transitionProject( projectId: string, roundId: string, newState: ProjectRoundStateValue, actorId: string, prisma: PrismaClient | any, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId } }) if (!round) { return { success: false, errors: [`Round ${roundId} not found`] } } if (round.status !== 'ROUND_ACTIVE') { return { success: false, errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`], } } // Verify project exists const project = await prisma.project.findUnique({ where: { id: projectId } }) if (!project) { return { success: false, errors: [`Project ${projectId} not found`] } } const result = await prisma.$transaction(async (tx: any) => { const now = new Date() // Upsert ProjectRoundState const existing = await tx.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, }) let prs if (existing) { prs = await tx.projectRoundState.update({ where: { id: existing.id }, data: { state: newState, exitedAt: isTerminalState(newState) ? now : null, }, }) } else { prs = await tx.projectRoundState.create({ data: { projectId, roundId, state: newState, enteredAt: now, }, }) } // Dual audit trail await tx.decisionAuditLog.create({ data: { eventType: 'project_round.transitioned', entityType: 'ProjectRoundState', entityId: prs.id, actorId, detailsJson: { projectId, roundId, previousState: existing?.state ?? null, newState, } as Prisma.InputJsonValue, snapshotJson: { timestamp: now.toISOString(), emittedBy: 'round-engine', }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'PROJECT_ROUND_TRANSITION', entityType: 'ProjectRoundState', entityId: prs.id, detailsJson: { projectId, roundId, newState, previousState: existing?.state ?? null }, }) return prs }) return { success: true, projectRoundState: { id: result.id, projectId: result.projectId, roundId: result.roundId, state: result.state, }, } } catch (error) { console.error('[RoundEngine] transitionProject failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error during project transition'], } } } /** * Batch transition projects in batches of BATCH_SIZE. * Each project is processed independently. */ export async function batchTransitionProjects( projectIds: string[], roundId: string, newState: ProjectRoundStateValue, actorId: string, prisma: PrismaClient | any, ): Promise { const succeeded: string[] = [] const failed: Array<{ projectId: string; errors: string[] }> = [] for (let i = 0; i < projectIds.length; i += BATCH_SIZE) { const batch = projectIds.slice(i, i + BATCH_SIZE) const batchPromises = batch.map(async (projectId) => { const result = await transitionProject(projectId, roundId, newState, actorId, prisma) if (result.success) { succeeded.push(projectId) } else { failed.push({ projectId, errors: result.errors ?? ['Transition failed'], }) } }) await Promise.all(batchPromises) } return { succeeded, failed, total: projectIds.length } } // ─── Query Helpers ────────────────────────────────────────────────────────── export async function getProjectRoundStates( roundId: string, prisma: PrismaClient | any, ) { return prisma.projectRoundState.findMany({ where: { roundId }, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, status: true, }, }, }, orderBy: { enteredAt: 'desc' }, }) } export async function getProjectRoundState( projectId: string, roundId: string, prisma: PrismaClient | any, ) { return prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, }) } // ─── Internals ────────────────────────────────────────────────────────────── function isTerminalState(state: ProjectRoundStateValue): boolean { return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state) }