Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
510
src/server/services/round-engine.ts
Normal file
510
src/server/services/round-engine.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user