511 lines
14 KiB
TypeScript
511 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* 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)
|
||
|
|
}
|