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

@@ -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<string, number> = {}
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,