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