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}
|
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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user