feat(assignments): reshuffle dropped juror projects within caps
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
This commit is contained in:
@@ -2358,6 +2358,7 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
||||
// ── Jury Progress Table ──────────────────────────────────────────────────
|
||||
|
||||
function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
@@ -2370,6 +2371,21 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
|
||||
if (data.failedCount > 0) {
|
||||
toast.warning(`Reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
|
||||
} else {
|
||||
toast.success(`Reassigned ${data.movedCount} project(s) evenly across available jurors.`)
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -2425,6 +2441,33 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
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.`
|
||||
)
|
||||
if (!ok) return
|
||||
reshuffleMutation.mutate({ roundId, jurorId: juror.id })
|
||||
}}
|
||||
>
|
||||
{reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
|
||||
@@ -172,6 +172,241 @@ export async function reassignAfterCOI(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function reassignDroppedJurorAssignments(params: {
|
||||
roundId: string
|
||||
droppedJurorId: string
|
||||
auditUserId?: string
|
||||
auditIp?: string
|
||||
auditUserAgent?: string
|
||||
}) {
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: params.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||
}
|
||||
|
||||
const droppedJuror = await prisma.user.findUnique({
|
||||
where: { id: params.droppedJurorId },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
if (!droppedJuror) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
|
||||
}
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const assignmentsToMove = await prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: params.roundId,
|
||||
userId: params.droppedJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: ['PENDING', 'DRAFT'] } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
evaluation: { select: { id: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (assignmentsToMove.length === 0) {
|
||||
return {
|
||||
movedCount: 0,
|
||||
failedCount: 0,
|
||||
failedProjects: [] as string[],
|
||||
reassignedTo: {} as Record<string, number>,
|
||||
}
|
||||
}
|
||||
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
maxAssignments: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
candidateJurors = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
id: { not: params.droppedJurorId },
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (candidateJurors.length === 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId: params.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: params.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
const caps = new Map<string, number>()
|
||||
for (const juror of candidateJurors) {
|
||||
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
||||
}
|
||||
|
||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||
const moves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string }[] = []
|
||||
const failedProjects: string[] = []
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
const eligible = candidateIds
|
||||
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
||||
.sort((a, b) => {
|
||||
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
||||
if (loadDiff !== 0) return loadDiff
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
if (eligible.length === 0) {
|
||||
failedProjects.push(assignment.project.title)
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedJurorId = eligible[0]
|
||||
moves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
newJurorId: selectedJurorId,
|
||||
})
|
||||
|
||||
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
if (moves.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const move of moves) {
|
||||
await tx.assignment.delete({ where: { id: move.assignmentId } })
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
roundId: params.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.newJurorId,
|
||||
method: 'MANUAL',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const reassignedTo: Record<string, number> = {}
|
||||
for (const move of moves) {
|
||||
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
if (moves.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}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
||||
})
|
||||
|
||||
const droppedName = droppedJuror.name || droppedJuror.email
|
||||
const topReceivers = Object.entries(reassignedTo)
|
||||
.map(([jurorId, count]) => {
|
||||
const juror = candidateMeta.get(jurorId)
|
||||
return `${juror?.name || juror?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
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.'}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
droppedJurorId: droppedJuror.id,
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
topReceivers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (params.auditUserId) {
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: params.auditUserId,
|
||||
action: 'JUROR_DROPOUT_RESHUFFLE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
droppedJurorId: droppedJuror.id,
|
||||
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
reassignedTo,
|
||||
},
|
||||
ipAddress: params.auditIp,
|
||||
userAgent: params.auditUserAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
reassignedTo,
|
||||
}
|
||||
}
|
||||
|
||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
await prisma.assignmentJob.update({
|
||||
@@ -1696,4 +1931,16 @@ export const assignmentRouter = router({
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
reassignDroppedJuror: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reassignDroppedJurorAssignments({
|
||||
roundId: input.roundId,
|
||||
droppedJurorId: input.jurorId,
|
||||
auditUserId: ctx.user.id,
|
||||
auditIp: ctx.ip,
|
||||
auditUserAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user