From d3a63b03546058762dcdf941ea1247f191696187 Mon Sep 17 00:00:00 2001 From: Claw Date: Thu, 19 Feb 2026 23:12:55 +0100 Subject: [PATCH] feat(assignments): reshuffle dropped juror projects within caps --- .../(admin)/admin/rounds/[roundId]/page.tsx | 43 +++ src/server/routers/assignment.ts | 247 ++++++++++++++++++ 2 files changed, 290 insertions(+) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 6e07fe9..ef098fa 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -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 ( @@ -2425,6 +2441,33 @@ function JuryProgressTable({ roundId }: { roundId: string }) {

Notify this juror of their assignments

+ + + + + + +

Drop juror + reshuffle pending projects

+
+
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index ae49ef8..6754c26 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -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 + 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, + } + } + + 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() + 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() + 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 = {} + 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, + }) + }), })