fix(assignments): make reshuffle concurrency-safe; preserve juryGroupId
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m16s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m16s
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -107,18 +107,21 @@ 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({
|
||||
// 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
|
||||
await createNotification({
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user