Files
MOPC-Portal/src/server/services/round-engine.ts

908 lines
28 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_ACTIVE', 'ROUND_ARCHIVED'],
ROUND_ARCHIVED: [],
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
PASSED: ['COMPLETED', 'WITHDRAWN'],
REJECTED: ['PENDING'], // re-include
COMPLETED: [], // terminal
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 | 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}`],
}
}
}
// 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: any) => {
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-passed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
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' },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// 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 },
})
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',
},
},
})
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 | any,
): 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: any) => {
// 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 | any,
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
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') {
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 } },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// 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 | any,
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
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) => {
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
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 | any,
) {
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 | any,
) {
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 | any,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Get all required FileRequirements for this round (legacy model)
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Also check SubmissionFileRequirement via the round's submissionWindow
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If the round has no file requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
return { transitioned: false }
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Check which legacy requirements this project has satisfied
let legacyAllMet = true
if (requirements.length > 0) {
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)
)
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Check which SubmissionFileRequirements this project has satisfied
let submissionAllMet = true
if (submissionRequirements.length > 0) {
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
where: {
projectId,
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
},
select: { submissionFileRequirementId: true },
})
const fulfilledSubIds = new Set(
fulfilledSubmissionFiles
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
.filter(Boolean)
)
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// All requirements from both models must be met
const allMet = legacyAllMet && submissionAllMet
if (!allMet) {
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 }
}
// All requirements met — transition to PASSED
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'PASSED' }
}
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 | any,
): Promise<{ transitionedCount: number; projectIds: string[] }> {
const transitioned: string[] = []
for (const projectId of projectIds) {
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
if (result.transitioned) {
transitioned.push(projectId)
}
}
if (transitioned.length > 0) {
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
}
return { transitionedCount: transitioned.length, projectIds: transitioned }
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
}