fix(assignments): make reshuffle concurrency-safe; preserve juryGroupId
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m16s

This commit is contained in:
Matt
2026-02-20 03:48:17 +01:00
parent c7f20e2f32
commit d9d6a63e4a
3 changed files with 103 additions and 25 deletions

View File

@@ -2452,7 +2452,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
disabled={reshuffleMutation.isPending} disabled={reshuffleMutation.isPending}
onClick={() => { onClick={() => {
const ok = window.confirm( 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 if (!ok) return
reshuffleMutation.mutate({ roundId, jurorId: juror.id }) reshuffleMutation.mutate({ roundId, jurorId: juror.id })

View File

@@ -107,17 +107,20 @@ export async function reassignAfterCOI(params: {
const replacement = underLimit[0] const replacement = underLimit[0]
// Delete old assignment (cascade deletes COI record and any draft evaluation) // Delete old assignment and create replacement atomically.
await prisma.assignment.delete({ where: { id: params.assignmentId } }) // Cascade deletes COI record and any draft evaluation.
const newAssignment = await prisma.$transaction(async (tx) => {
// Create new assignment await tx.assignment.delete({ where: { id: params.assignmentId } })
const newAssignment = await prisma.assignment.create({ return tx.assignment.create({
data: { data: {
userId: replacement.id, userId: replacement.id,
projectId, projectId,
roundId, roundId,
method: 'MANUAL', juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
}, isRequired: assignment.isRequired,
method: 'MANUAL',
},
})
}) })
// Notify the replacement juror // 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: { async function reassignDroppedJurorAssignments(params: {
roundId: string roundId: string
droppedJurorId: string droppedJurorId: string
@@ -203,18 +209,22 @@ async function reassignDroppedJurorAssignments(params: {
(config.maxAssignmentsPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ??
20 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({ const assignmentsToMove = await prisma.assignment.findMany({
where: { where: {
roundId: params.roundId, roundId: params.roundId,
userId: params.droppedJurorId, userId: params.droppedJurorId,
OR: [ OR: [
{ evaluation: null }, { evaluation: null },
{ evaluation: { status: { not: 'SUBMITTED' } } }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
], ],
}, },
select: { select: {
id: true, id: true,
projectId: true, projectId: true,
juryGroupId: true,
isRequired: true,
createdAt: true, createdAt: true,
project: { select: { title: true } }, project: { select: { title: true } },
}, },
@@ -295,7 +305,14 @@ async function reassignDroppedJurorAssignments(params: {
} }
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j])) 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[] = [] const failedProjects: string[] = []
for (const assignment of assignmentsToMove) { for (const assignment of assignmentsToMove) {
@@ -315,44 +332,79 @@ async function reassignDroppedJurorAssignments(params: {
} }
const selectedJurorId = eligible[0] const selectedJurorId = eligible[0]
moves.push({ plannedMoves.push({
assignmentId: assignment.id, assignmentId: assignment.id,
projectId: assignment.projectId, projectId: assignment.projectId,
projectTitle: assignment.project.title, projectTitle: assignment.project.title,
newJurorId: selectedJurorId, newJurorId: selectedJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
}) })
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`) alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1) 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) => { await prisma.$transaction(async (tx) => {
for (const move of moves) { for (const move of plannedMoves) {
await tx.assignment.delete({ where: { id: move.assignmentId } }) // 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({ await tx.assignment.create({
data: { data: {
roundId: params.roundId, roundId: params.roundId,
projectId: move.projectId, projectId: move.projectId,
userId: move.newJurorId, userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL', method: 'MANUAL',
createdBy: params.auditUserId ?? undefined,
}, },
}) })
actualMoves.push(move)
} }
}) })
} }
// Add skipped projects to the failed list
failedProjects.push(...skippedProjects)
const reassignedTo: Record<string, number> = {} const reassignedTo: Record<string, number> = {}
for (const move of moves) { for (const move of actualMoves) {
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1 reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
} }
if (moves.length > 0) { if (actualMoves.length > 0) {
await createBulkNotifications({ await createBulkNotifications({
userIds: Object.keys(reassignedTo), userIds: Object.keys(reassignedTo),
type: NotificationTypes.BATCH_ASSIGNED, type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects 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`, linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments', linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' }, metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
@@ -369,12 +421,12 @@ async function reassignDroppedJurorAssignments(params: {
await notifyAdmins({ await notifyAdmins({
type: NotificationTypes.BATCH_ASSIGNED, type: NotificationTypes.BATCH_ASSIGNED,
title: 'Juror Dropout Reshuffle', 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}`, linkUrl: `/admin/rounds/${round.id}`,
metadata: { metadata: {
roundId: round.id, roundId: round.id,
droppedJurorId: droppedJuror.id, droppedJurorId: droppedJuror.id,
movedCount: moves.length, movedCount: actualMoves.length,
failedCount: failedProjects.length, failedCount: failedProjects.length,
topReceivers, topReceivers,
}, },
@@ -391,9 +443,10 @@ async function reassignDroppedJurorAssignments(params: {
detailsJson: { detailsJson: {
droppedJurorId: droppedJuror.id, droppedJurorId: droppedJuror.id,
droppedJurorName: droppedJuror.name || droppedJuror.email, droppedJurorName: droppedJuror.name || droppedJuror.email,
movedCount: moves.length, movedCount: actualMoves.length,
failedCount: failedProjects.length, failedCount: failedProjects.length,
failedProjects, failedProjects,
skippedProjects,
reassignedTo, reassignedTo,
}, },
ipAddress: params.auditIp, ipAddress: params.auditIp,
@@ -402,7 +455,7 @@ async function reassignDroppedJurorAssignments(params: {
} }
return { return {
movedCount: moves.length, movedCount: actualMoves.length,
failedCount: failedProjects.length, failedCount: failedProjects.length,
failedProjects, failedProjects,
reassignedTo, reassignedTo,

View File

@@ -15,6 +15,7 @@ import type {
CompetitionStatus, CompetitionStatus,
ProjectRoundStateValue, ProjectRoundStateValue,
AssignmentMethod, AssignmentMethod,
EvaluationStatus,
} from '@prisma/client' } from '@prisma/client'
export function uid(prefix = 'test'): string { export function uid(prefix = 'test'): string {
@@ -176,6 +177,7 @@ export async function createTestAssignment(
overrides: Partial<{ overrides: Partial<{
method: AssignmentMethod method: AssignmentMethod
isCompleted: boolean isCompleted: boolean
juryGroupId: string
}> = {}, }> = {},
) { ) {
return prisma.assignment.create({ return prisma.assignment.create({
@@ -185,6 +187,29 @@ export async function createTestAssignment(
roundId, roundId,
method: overrides.method ?? 'MANUAL', method: overrides.method ?? 'MANUAL',
isCompleted: overrides.isCompleted ?? false, 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,
}, },
}) })
} }