/** * 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' import { processRoundClose } from './round-finalization' import { sendMentorBulkAssignmentEmail, sendTeamMentorIntroductionEmail, } from '@/lib/email' // ─── 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_ACTIVE', 'ROUND_ARCHIVED'], ROUND_ARCHIVED: [], } const VALID_PROJECT_TRANSITIONS: Record = { PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'], IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'], COMPLETED: ['PASSED', 'REJECTED'], PASSED: ['IN_PROGRESS', 'WITHDRAWN'], REJECTED: ['PENDING'], // re-include WITHDRAWN: ['PENDING'], // re-include } // ─── 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, ): 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}`], } } } // If activating before the scheduled start, snap windowOpenAt to now const now = new Date() const windowData: Record = {} if (round.windowOpenAt && new Date(round.windowOpenAt) > now) { windowData.windowOpenAt = now } // If no windowOpenAt was set at all, also set it to now if (!round.windowOpenAt) { windowData.windowOpenAt = now } const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ACTIVE', ...windowData }, }) 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', }, }, }) return result }) // Audit log outside transaction to avoid FK violations poisoning the tx await logAudit({ userId: actorId, action: 'ROUND_ACTIVATE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name, roundType: round.roundType }, }) // Retroactive check: auto-PASS any projects that already have all required docs uploaded // Non-fatal — runs after activation so it never blocks the transition try { const projectStates = await prisma.projectRoundState.findMany({ where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } }, select: { projectId: true }, }) if (projectStates.length > 0) { 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-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) 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) ?? {} // 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) } // Mentor-side coalesced emails on round open. Picks up every assignment // for projects in this round whose notificationSentAt is null (i.e. // assignments made while the round was still in draft), groups by // mentor, and sends a single combined email per mentor listing all // their projects in this round. try { const pendingAssignments = await prisma.mentorAssignment.findMany({ where: { droppedAt: null, notificationSentAt: null, project: { projectRoundStates: { some: { roundId } } }, }, select: { id: true, mentorId: true, mentor: { select: { name: true, email: true } }, project: { select: { id: true, title: true } }, }, }) const perMentor = new Map< string, { email: string | null name: string | null assignmentIds: string[] projects: { id: string; title: string }[] } >() for (const a of pendingAssignments) { if (!a.mentor?.email) continue const bucket = perMentor.get(a.mentorId) ?? { email: a.mentor.email, name: a.mentor.name, assignmentIds: [], projects: [], } bucket.assignmentIds.push(a.id) bucket.projects.push({ id: a.project.id, title: a.project.title }) perMentor.set(a.mentorId, bucket) } for (const bucket of perMentor.values()) { if (bucket.projects.length === 0 || !bucket.email) continue await sendMentorBulkAssignmentEmail( bucket.email, bucket.name, bucket.projects, ) await prisma.mentorAssignment.updateMany({ where: { id: { in: bucket.assignmentIds } }, data: { notificationSentAt: new Date() }, }) } if (perMentor.size > 0) { console.log( `[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`, ) } } catch (mentorEmailError) { console.error( '[RoundEngine] Mentor-side coalesced notification failed (non-fatal):', mentorEmailError, ) } // Introduce teams to their mentors via email when the round opens. // Idempotent via MentorAssignment.teamIntroducedAt — separate from the // mentor-side notificationSentAt so the team email fires even when the // mentor was assigned (and notified) before the round opened. try { const projectsToIntroduce = await prisma.project.findMany({ where: { projectRoundStates: { some: { roundId } }, mentorAssignments: { some: { droppedAt: null, teamIntroducedAt: null }, }, }, select: { id: true, title: true, mentorAssignments: { where: { droppedAt: null }, select: { id: true, teamIntroducedAt: true, mentor: { select: { name: true, email: true } }, }, }, teamMembers: { select: { user: { select: { name: true, email: true } } }, }, submittedByEmail: true, submittedBy: { select: { name: true } }, }, }) for (const p of projectsToIntroduce) { const mentors = p.mentorAssignments .filter((a) => a.mentor?.email) .map((a) => ({ name: a.mentor.name, email: a.mentor.email, })) if (mentors.length === 0) continue // Build a unique recipient set: team-member users with emails, // plus the original submitter (in case they're not on the team yet). const recipients = new Map() for (const tm of p.teamMembers) { if (tm.user?.email) { recipients.set(tm.user.email, { name: tm.user.name }) } } if ( p.submittedByEmail && !recipients.has(p.submittedByEmail) ) { recipients.set(p.submittedByEmail, { name: p.submittedBy?.name ?? null, }) } for (const [email, { name }] of recipients) { await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors) } // Stamp every mentor-assignment row so re-activation doesn't re-send. const idsToStamp = p.mentorAssignments .filter((a) => a.teamIntroducedAt == null) .map((a) => a.id) if (idsToStamp.length > 0) { await prisma.mentorAssignment.updateMany({ where: { id: { in: idsToStamp } }, data: { teamIntroducedAt: new Date() }, }) } } if (projectsToIntroduce.length > 0) { console.log( `[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`, ) } } catch (introError) { console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError) } } 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, ): 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: Prisma.TransactionClient) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_CLOSED' }, }) // Expire pending intents (using the transaction client) await expireIntentsForRound(roundId, actorId, tx) // Auto-close any preceding active rounds (lower sortOrder, same competition) const precedingActiveRounds = await tx.round.findMany({ where: { competitionId: round.competitionId, sortOrder: { lt: round.sortOrder }, status: 'ROUND_ACTIVE', }, orderBy: { sortOrder: 'asc' }, }) for (const prev of precedingActiveRounds) { await tx.round.update({ where: { id: prev.id }, data: { status: 'ROUND_CLOSED' }, }) await tx.decisionAuditLog.create({ data: { eventType: 'round.closed', entityType: 'Round', entityId: prev.id, actorId, detailsJson: { roundName: prev.name, roundType: prev.roundType, previousStatus: 'ROUND_ACTIVE', closedBy: 'cascade', triggeringRoundId: roundId, }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) } await tx.decisionAuditLog.create({ data: { eventType: 'round.closed', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, roundType: round.roundType, previousStatus: 'ROUND_ACTIVE', cascadeClosed: precedingActiveRounds.map((r: any) => r.name), }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) return result }) // Audit log outside transaction to avoid FK violations poisoning the tx await logAudit({ userId: actorId, action: 'ROUND_CLOSE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name, roundType: round.roundType }, }) // Grace period / immediate finalization processing try { const config = round.configJson ? (round.configJson as Record) : {} 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 }, } } 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, ): 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: Prisma.TransactionClient) => { 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', }, }, }) return result }) // Audit log outside transaction to avoid FK violations poisoning the tx await logAudit({ userId: actorId, action: 'ROUND_ARCHIVE', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name }, }) 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'], } } } /** * Reopen a round: ROUND_CLOSED → ROUND_ACTIVE * Side effects: any subsequent rounds in the same competition that are * ROUND_ACTIVE will be paused (set to ROUND_CLOSED) to prevent two * active rounds overlapping. */ export async function reopenRound( roundId: string, actorId: string, prisma: PrismaClient, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId }, include: { competition: true }, }) if (!round) { return { success: false, errors: [`Round ${roundId} not found`] } } if (round.status !== 'ROUND_CLOSED') { return { success: false, errors: [`Cannot reopen round: current status is ${round.status}, expected ROUND_CLOSED`], } } const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Pause any subsequent active rounds in the same competition const subsequentActiveRounds = await tx.round.findMany({ where: { competitionId: round.competitionId, sortOrder: { gt: round.sortOrder }, status: 'ROUND_ACTIVE', }, select: { id: true, name: true }, }) if (subsequentActiveRounds.length > 0) { await tx.round.updateMany({ where: { id: { in: subsequentActiveRounds.map((r: any) => r.id) } }, data: { status: 'ROUND_CLOSED' }, }) // Audit each paused round for (const paused of subsequentActiveRounds) { await tx.decisionAuditLog.create({ data: { eventType: 'round.paused', entityType: 'Round', entityId: paused.id, actorId, detailsJson: { roundName: paused.name, reason: `Paused because prior round "${round.name}" was reopened`, previousStatus: 'ROUND_ACTIVE', }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) } } // Reopen this round — clear windowCloseAt so the voting window check // doesn't reject submissions with "Voting window has closed". const updated = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ACTIVE', windowCloseAt: null }, }) await tx.decisionAuditLog.create({ data: { eventType: 'round.reopened', entityType: 'Round', entityId: roundId, actorId, detailsJson: { roundName: round.name, previousStatus: 'ROUND_CLOSED', pausedRounds: subsequentActiveRounds.map((r: any) => r.name), }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'round-engine', }, }, }) return { updated, pausedRounds: subsequentActiveRounds.map((r: any) => r.name), } }) // Audit log outside transaction to avoid FK violations poisoning the tx await logAudit({ userId: actorId, action: 'ROUND_REOPEN', entityType: 'Round', entityId: roundId, detailsJson: { name: round.name, pausedRounds: result.pausedRounds, }, }) // Retroactive check: auto-PASS any projects that already have all required docs try { const projectStates = await prisma.projectRoundState.findMany({ where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } }, select: { projectId: true }, }) if (projectStates.length > 0) { const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId) const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma) if (batchResult.transitionedCount > 0) { console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`) } } } catch (retroError) { console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError) } return { success: true, round: { id: result.updated.id, status: result.updated.status }, pausedRounds: result.pausedRounds, } } catch (error) { console.error('[RoundEngine] reopenRound failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error during round reopen'], } } } // ─── 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, options?: { adminOverride?: boolean }, ): 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' && round.status !== 'ROUND_CLOSED') { return { success: false, errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED 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: Prisma.TransactionClient) => { const now = new Date() // Upsert ProjectRoundState const existing = await tx.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, }) // Enforce project state transition whitelist (unless admin override) if (existing && !options?.adminOverride) { const currentState = existing.state as string const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? [] if (!allowed.includes(newState)) { throw new Error( `Invalid project transition: ${currentState} → ${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`, ) } } 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', }, }, }) return { prs, previousState: existing?.state ?? null } }) // Audit log outside transaction to avoid FK violations poisoning the tx await logAudit({ userId: actorId, action: 'PROJECT_ROUND_TRANSITION', entityType: 'ProjectRoundState', entityId: result.prs.id, detailsJson: { projectId, roundId, newState, previousState: result.previousState }, }) return { success: true, projectRoundState: { id: result.prs.id, projectId: result.prs.projectId, roundId: result.prs.roundId, state: result.prs.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, options?: { adminOverride?: boolean }, ): 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, options) 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, ) { const states = await prisma.projectRoundState.findMany({ where: { roundId }, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, status: true, assignments: { where: { roundId }, select: { id: true, isCompleted: true, evaluation: { select: { status: true } }, }, }, }, }, }, orderBy: { enteredAt: 'desc' }, }) // Compute evaluation progress per project return states.map((ps: any) => { const assignments = ps.project?.assignments ?? [] const totalAssignments = assignments.length const submittedCount = assignments.filter( (a: any) => a.evaluation?.status === 'SUBMITTED' ).length return { ...ps, totalAssignments, submittedCount, project: { ...ps.project, assignments: undefined, // strip raw assignments from response }, } }) } export async function getProjectRoundState( projectId: string, roundId: string, prisma: PrismaClient, ) { return prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, }) } // ─── Auto-Transition on Document Completion ───────────────────────────────── /** * Check if a project has fulfilled all required FileRequirements for a round. * If yes, and the project is currently PENDING, transition it to PASSED. * * Called after file uploads (admin bulk upload or applicant upload). * Non-fatal: errors are logged but never propagated to callers. */ export async function checkRequirementsAndTransition( projectId: string, roundId: string, actorId: string, prisma: PrismaClient, ): Promise<{ transitioned: boolean; newState?: string }> { try { // Get all required FileRequirements for this round // Note: only FileRequirement (admin-managed via UI) is checked. // SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked. const requirements = await prisma.fileRequirement.findMany({ where: { roundId, isRequired: true }, select: { id: true }, }) // If the round has no file requirements, nothing to check if (requirements.length === 0) { return { transitioned: false } } // Check which requirements this project has satisfied const fulfilledFiles = await prisma.projectFile.findMany({ where: { projectId, roundId, requirementId: { in: requirements.map((r: { id: string }) => r.id) }, }, select: { requirementId: true }, }) const fulfilledIds = new Set( fulfilledFiles .map((f: { requirementId: string | null }) => f.requirementId) .filter(Boolean) ) if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) { return { transitioned: false } } // Check current state — only transition if PENDING or IN_PROGRESS const currentState = await prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, select: { state: true }, }) const eligibleStates = ['PENDING', 'IN_PROGRESS'] if (!currentState || !eligibleStates.includes(currentState.state)) { return { transitioned: false } } // 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 COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`) return { transitioned: true, newState: 'COMPLETED' } } return { transitioned: false } } catch (error) { // Non-fatal — log and continue console.error('[RoundEngine] checkRequirementsAndTransition failed:', error) return { transitioned: false } } } /** * Batch version: check all projects in a round and transition any that * have all required documents uploaded. Useful after bulk upload. */ export async function batchCheckRequirementsAndTransition( roundId: string, projectIds: string[], actorId: string, prisma: PrismaClient, ): Promise<{ transitionedCount: number; projectIds: string[] }> { if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] } // Pre-load all requirements for this round in batch (avoids per-project queries) // Note: only FileRequirement (admin-managed via UI) is checked. // SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked. const requirements = await prisma.fileRequirement.findMany({ where: { roundId, isRequired: true }, select: { id: true }, }) // If no requirements, nothing to check if (requirements.length === 0) { return { transitionedCount: 0, projectIds: [] } } // Pre-load all project files and current states in batch type FileRow = { projectId: string; requirementId: string | null } type StateRow = { projectId: string; state: string } const [allFiles, allStates] = await Promise.all([ prisma.projectFile.findMany({ where: { projectId: { in: projectIds }, roundId, }, select: { projectId: true, requirementId: true }, }) as Promise, prisma.projectRoundState.findMany({ where: { roundId, projectId: { in: projectIds } }, select: { projectId: true, state: true }, }) as Promise, ]) // Build per-project lookup maps const filesByProject = new Map() for (const f of allFiles) { const arr = filesByProject.get(f.projectId) ?? [] arr.push(f) filesByProject.set(f.projectId, arr) } const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state])) // Determine which projects have all requirements met and are eligible for transition const eligibleStates = ['PENDING', 'IN_PROGRESS'] const toTransition: string[] = [] for (const projectId of projectIds) { const currentState = stateByProject.get(projectId) if (!currentState || !eligibleStates.includes(currentState)) continue const files = filesByProject.get(projectId) ?? [] const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean)) if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue toTransition.push(projectId) } // Transition eligible projects (still uses transitionProject for state machine correctness) const transitioned: string[] = [] for (const projectId of toTransition) { const currentState = stateByProject.get(projectId) // If PENDING, first move to IN_PROGRESS if (currentState === 'PENDING') { await triggerInProgressOnActivity(projectId, roundId, actorId, prisma) } const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma) if (result.success) { transitioned.push(projectId) } } if (transitioned.length > 0) { 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, ): Promise { 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, ): 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 ────────────────────────────────────────────────────────────── export function isTerminalState(state: ProjectRoundStateValue): boolean { return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state) }