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

1139 lines
37 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'
import { processRoundClose } from './round-finalization'
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
import { 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: [],
}
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', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
COMPLETED: ['PASSED', 'REJECTED'],
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
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
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)
}
feat(mentor): bulk assignment + coalesced emails + team intros on round open Round-page bulk-assign UI - Checkboxes on every project row, header select-all, primary-tinted action toolbar that appears when 1+ rows are selected with an "Assign mentor…" CTA and Clear. Dialog lists the mentor pool with search (name/email/ country/expertise), load indicator, and a radio picker. - Always-visible tip strip when nothing is selected explains the bulk flow and offers a one-click "Select all N without a mentor" shortcut. - New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns one mentor to many projects in a transaction; idempotent on the per-pair `(projectId, mentorId)` unique; per-project in-app notifications still fire for each team. - Mutation invalidates listMentoringProjects, getProjectsNeedingMentor, getMentoringImportCandidates, getMentorPool, getRoundStats, project.list so the page reflects the new state without a refresh. Coalesced mentor emails - New `sendMentorBulkAssignmentEmail` (single email listing every newly- assigned project + workspace links) used by `mentor.bulkAssign` and `mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow now emails mentors at the end of the batch, one combined email per mentor regardless of how many projects they received. Team introduction emails when the round opens - New `sendTeamMentorIntroductionEmail` lists every assigned mentor with name + email and a link to the workspace, so teams can reach out directly. - `activateRound` (round-engine) fires the introduction for every project in a MENTORING round that has active mentors when the round opens. - `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also fire the introduction immediately when the project's MENTORING round is already ROUND_ACTIVE — so mentors added mid-round still reach the team. - Idempotency via the new `MentorAssignment.teamIntroducedAt` column (migration 20260526114936) — independent from `notificationSentAt` so pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
// 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,
})
}
for (const [email, { name }] of recipients) {
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors)
}
// 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' },
})
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 },
})
// 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,
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' && 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 } },
})
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,
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,
) {
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)
}