feat(assignments): reshuffle dropped juror projects within caps
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s

This commit is contained in:
Claw
2026-02-19 23:12:55 +01:00
parent 9d945c33f9
commit d3a63b0354
2 changed files with 290 additions and 0 deletions

View File

@@ -2358,6 +2358,7 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
// ── Jury Progress Table ────────────────────────────────────────────────── // ── Jury Progress Table ──────────────────────────────────────────────────
function JuryProgressTable({ roundId }: { roundId: string }) { function JuryProgressTable({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery( const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: 15_000 },
@@ -2370,6 +2371,21 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
onError: (err) => toast.error(err.message), 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -2425,6 +2441,33 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent> <TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </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> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">

View File

@@ -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) { async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
try { try {
await prisma.assignmentJob.update({ await prisma.assignmentJob.update({
@@ -1696,4 +1931,16 @@ export const assignmentRouter = router({
return result 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,
})
}),
}) })