diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index ef098fa..37e6852 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -2452,7 +2452,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) { disabled={reshuffleMutation.isPending} onClick={() => { const ok = window.confirm( - `Reassign all pending/draft projects from ${juror.name} to other jurors within their caps? This cannot be undone.` + `Reassign all unsubmitted projects from ${juror.name} to other jurors within their caps? Submitted and locked evaluations will be preserved. This cannot be undone.` ) if (!ok) return reshuffleMutation.mutate({ roundId, jurorId: juror.id }) diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 74fe9ad..4956b4b 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -107,17 +107,20 @@ export async function reassignAfterCOI(params: { const replacement = underLimit[0] - // Delete old assignment (cascade deletes COI record and any draft evaluation) - await prisma.assignment.delete({ where: { id: params.assignmentId } }) - - // Create new assignment - const newAssignment = await prisma.assignment.create({ - data: { - userId: replacement.id, - projectId, - roundId, - method: 'MANUAL', - }, + // Delete old assignment and create replacement atomically. + // Cascade deletes COI record and any draft evaluation. + const newAssignment = await prisma.$transaction(async (tx) => { + await tx.assignment.delete({ where: { id: params.assignmentId } }) + return tx.assignment.create({ + data: { + userId: replacement.id, + projectId, + roundId, + juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined, + isRequired: assignment.isRequired, + method: 'MANUAL', + }, + }) }) // Notify the replacement juror @@ -172,6 +175,9 @@ export async function reassignAfterCOI(params: { } } +/** Evaluation statuses that are safe to move (not yet finalized). */ +const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const + async function reassignDroppedJurorAssignments(params: { roundId: string droppedJurorId: string @@ -203,18 +209,22 @@ async function reassignDroppedJurorAssignments(params: { (config.maxAssignmentsPerJuror as number) ?? 20 + // Only pick assignments with no evaluation or evaluation still in draft/not-started. + // Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched. const assignmentsToMove = await prisma.assignment.findMany({ where: { roundId: params.roundId, userId: params.droppedJurorId, OR: [ { evaluation: null }, - { evaluation: { status: { not: 'SUBMITTED' } } }, + { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, select: { id: true, projectId: true, + juryGroupId: true, + isRequired: true, createdAt: true, project: { select: { title: true } }, }, @@ -295,7 +305,14 @@ async function reassignDroppedJurorAssignments(params: { } const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j])) - const moves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string }[] = [] + const plannedMoves: { + assignmentId: string + projectId: string + projectTitle: string + newJurorId: string + juryGroupId: string | null + isRequired: boolean + }[] = [] const failedProjects: string[] = [] for (const assignment of assignmentsToMove) { @@ -315,44 +332,79 @@ async function reassignDroppedJurorAssignments(params: { } const selectedJurorId = eligible[0] - moves.push({ + plannedMoves.push({ assignmentId: assignment.id, projectId: assignment.projectId, projectTitle: assignment.project.title, newJurorId: selectedJurorId, + juryGroupId: assignment.juryGroupId ?? round.juryGroupId, + isRequired: assignment.isRequired, }) alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`) currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1) } - if (moves.length > 0) { + // Execute moves inside a transaction with per-move TOCTOU guard. + // Uses conditional deleteMany so a concurrent evaluation submission + // (which sets status to SUBMITTED) causes the delete to return count=0 + // instead of cascade-destroying the submitted evaluation. + const actualMoves: typeof plannedMoves = [] + const skippedProjects: string[] = [] + + if (plannedMoves.length > 0) { await prisma.$transaction(async (tx) => { - for (const move of moves) { - await tx.assignment.delete({ where: { id: move.assignmentId } }) + for (const move of plannedMoves) { + // Guard: only delete if the assignment still belongs to the dropped juror + // AND its evaluation (if any) is still in a movable state. + // If a juror submitted between our read and now, count will be 0. + const deleted = await tx.assignment.deleteMany({ + where: { + id: move.assignmentId, + userId: params.droppedJurorId, + OR: [ + { evaluation: null }, + { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, + ], + }, + }) + + if (deleted.count === 0) { + // Assignment was already moved, deleted, or its evaluation was submitted + skippedProjects.push(move.projectTitle) + continue + } + await tx.assignment.create({ data: { roundId: params.roundId, projectId: move.projectId, userId: move.newJurorId, + juryGroupId: move.juryGroupId ?? undefined, + isRequired: move.isRequired, method: 'MANUAL', + createdBy: params.auditUserId ?? undefined, }, }) + actualMoves.push(move) } }) } + // Add skipped projects to the failed list + failedProjects.push(...skippedProjects) + const reassignedTo: Record = {} - for (const move of moves) { + for (const move of actualMoves) { reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1 } - if (moves.length > 0) { + if (actualMoves.length > 0) { await createBulkNotifications({ userIds: Object.keys(reassignedTo), type: NotificationTypes.BATCH_ASSIGNED, title: 'Additional Projects Assigned', - message: `You have received additional project assignment${Object.keys(reassignedTo).length > 1 ? 's' : ''} due to a jury reassignment in ${round.name}.`, + message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' }, @@ -369,12 +421,12 @@ async function reassignDroppedJurorAssignments(params: { await notifyAdmins({ type: NotificationTypes.BATCH_ASSIGNED, title: 'Juror Dropout Reshuffle', - message: `Reassigned ${moves.length} project(s) from ${droppedName}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned.'}`, + message: `Reassigned ${actualMoves.length} project(s) from ${droppedName}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned.'}`, linkUrl: `/admin/rounds/${round.id}`, metadata: { roundId: round.id, droppedJurorId: droppedJuror.id, - movedCount: moves.length, + movedCount: actualMoves.length, failedCount: failedProjects.length, topReceivers, }, @@ -391,9 +443,10 @@ async function reassignDroppedJurorAssignments(params: { detailsJson: { droppedJurorId: droppedJuror.id, droppedJurorName: droppedJuror.name || droppedJuror.email, - movedCount: moves.length, + movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects, + skippedProjects, reassignedTo, }, ipAddress: params.auditIp, @@ -402,7 +455,7 @@ async function reassignDroppedJurorAssignments(params: { } return { - movedCount: moves.length, + movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects, reassignedTo, diff --git a/tests/helpers.ts b/tests/helpers.ts index 45ef674..160036f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -15,6 +15,7 @@ import type { CompetitionStatus, ProjectRoundStateValue, AssignmentMethod, + EvaluationStatus, } from '@prisma/client' export function uid(prefix = 'test'): string { @@ -176,6 +177,7 @@ export async function createTestAssignment( overrides: Partial<{ method: AssignmentMethod isCompleted: boolean + juryGroupId: string }> = {}, ) { return prisma.assignment.create({ @@ -185,6 +187,29 @@ export async function createTestAssignment( roundId, method: overrides.method ?? 'MANUAL', isCompleted: overrides.isCompleted ?? false, + juryGroupId: overrides.juryGroupId ?? undefined, + }, + }) +} + +// ─── Evaluation Factory ─────────────────────────────────────────────────── + +export async function createTestEvaluation( + assignmentId: string, + formId: string, + overrides: Partial<{ + status: 'NOT_STARTED' | 'DRAFT' | 'SUBMITTED' | 'LOCKED' + globalScore: number + submittedAt: Date + }> = {}, +) { + return prisma.evaluation.create({ + data: { + assignmentId, + formId, + status: overrides.status ?? 'NOT_STARTED', + globalScore: overrides.globalScore ?? null, + submittedAt: overrides.submittedAt ?? null, }, }) }