MOPC-App/src/server/services/round-engine.ts

511 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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<string, string[]> = {
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<RoundTransitionResult> {
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<string, unknown>,
)
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<RoundTransitionResult> {
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<RoundTransitionResult> {
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<ProjectRoundTransitionResult> {
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<BatchProjectTransitionResult> {
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)
}