1223 lines
40 KiB
TypeScript
1223 lines
40 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'
|
|
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<string, string[]> = {
|
|
ROUND_DRAFT: ['ROUND_ACTIVE'],
|
|
ROUND_ACTIVE: ['ROUND_CLOSED'],
|
|
ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
|
|
ROUND_ARCHIVED: [],
|
|
}
|
|
|
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
|
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<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}`],
|
|
}
|
|
}
|
|
}
|
|
|
|
// If activating before the scheduled start, snap windowOpenAt to now
|
|
const now = new Date()
|
|
const windowData: Record<string, Date> = {}
|
|
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<string, unknown>)
|
|
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<string, unknown>) ?? {}
|
|
// 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,
|
|
teamMembers: { select: { user: { select: { name: true, email: true } } } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const perMentor = new Map<
|
|
string,
|
|
{
|
|
email: string | null
|
|
name: string | null
|
|
assignmentIds: string[]
|
|
projects: { id: string; title: string; teamMembers: { name: string | null; email: 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,
|
|
teamMembers: a.project.teamMembers
|
|
.filter((tm) => tm.user?.email)
|
|
.map((tm) => ({ name: tm.user.name, email: tm.user.email })),
|
|
})
|
|
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<string, { name: string | null }>()
|
|
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,
|
|
})
|
|
}
|
|
|
|
const allMembers = p.teamMembers
|
|
.filter((tm) => tm.user?.email)
|
|
.map((tm) => ({ name: tm.user.name, email: tm.user.email }))
|
|
|
|
for (const [email, { name }] of recipients) {
|
|
const teammates = allMembers.filter((m) => m.email !== email)
|
|
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates)
|
|
}
|
|
|
|
// 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<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: 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<string, unknown>) : {}
|
|
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<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: 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<RoundTransitionResult & { pausedRounds?: string[] }> {
|
|
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<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' && 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<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, 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<FileRow[]>,
|
|
prisma.projectRoundState.findMany({
|
|
where: { roundId, projectId: { in: projectIds } },
|
|
select: { projectId: true, state: true },
|
|
}) as Promise<StateRow[]>,
|
|
])
|
|
|
|
// Build per-project lookup maps
|
|
const filesByProject = new Map<string, FileRow[]>()
|
|
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<void> {
|
|
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)
|
|
}
|