From 0ff84686f0cd5c30be9357df95b7ad0f5ed919fb Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Feb 2026 18:30:01 +0100 Subject: [PATCH] Auto-reassign projects when juror declares conflict of interest When a juror declares COI, the system now automatically: - Finds an eligible replacement juror (not at capacity, no COI, not already assigned) - Deletes the conflicted assignment and creates a new one - Notifies the replacement juror and admins - Load-balances by picking the juror with fewest current assignments Also adds: - "Reassign (COI)" action in assignment table dropdown with COI badge indicator - Admin "Reassign to another juror" in COI review now triggers actual reassignment - Per-juror notify button is now always visible (not just on hover) - reassignCOI admin procedure for retroactive manual reassignment Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 67 +++++-- src/server/routers/assignment.ts | 176 ++++++++++++++++++ src/server/routers/evaluation.ts | 33 +++- 3 files changed, 259 insertions(+), 17 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index a57dd13..6e07fe9 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -2411,7 +2411,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) { + {a.conflictOfInterest?.hasConflict && ( + <> + reassignCOIMutation.mutate({ assignmentId: a.id })} + disabled={reassignCOIMutation.isPending} + > + + Reassign (COI) + + + + )} {a.evaluation && ( <> { + onSuccess: (data) => { utils.evaluation.listCOIByStage.invalidate({ roundId }) - toast.success('COI review updated') + utils.assignment.listByStage.invalidate({ roundId }) + utils.analytics.getJurorWorkload.invalidate({ roundId }) + if (data.reassignment) { + toast.success(`Reassigned to ${data.reassignment.newJurorName}`) + } else { + toast.success('COI review updated') + } }, onError: (err) => toast.error(err.message), }) diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 91d0cc4..ae49ef8 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -17,6 +17,161 @@ import { } from '../services/in-app-notification' import { logAudit } from '@/server/utils/audit' +/** + * Reassign a project after a juror declares COI. + * Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment. + * Returns the new juror info or null if no eligible juror found. + */ +export async function reassignAfterCOI(params: { + assignmentId: string + auditUserId?: string + auditIp?: string + auditUserAgent?: string +}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> { + const assignment = await prisma.assignment.findUnique({ + where: { id: params.assignmentId }, + include: { + round: { select: { id: true, name: true, configJson: true, juryGroupId: true } }, + project: { select: { id: true, title: true } }, + user: { select: { id: true, name: true, email: true } }, + }, + }) + + if (!assignment) return null + + const { roundId, projectId } = assignment + const config = (assignment.round.configJson ?? {}) as Record + const maxAssignmentsPerJuror = + (config.maxLoadPerJuror as number) ?? + (config.maxAssignmentsPerJuror as number) ?? + 20 + + // Get all jurors already assigned to this project in this round + const existingAssignments = await prisma.assignment.findMany({ + where: { roundId, projectId }, + select: { userId: true }, + }) + const alreadyAssignedIds = new Set(existingAssignments.map((a) => a.userId)) + + // Get all COI records for this project (any juror who declared conflict) + const coiRecords = await prisma.conflictOfInterest.findMany({ + where: { projectId, hasConflict: true }, + select: { userId: true }, + }) + const coiUserIds = new Set(coiRecords.map((c) => c.userId)) + + // Find eligible jurors: in the jury group (or all JURY_MEMBERs), not already assigned, no COI + let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[] + + if (assignment.round.juryGroupId) { + const members = await prisma.juryGroupMember.findMany({ + where: { juryGroupId: assignment.round.juryGroupId }, + include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } }, + }) + candidateJurors = members + .filter((m) => m.user.status === 'ACTIVE') + .map((m) => m.user) + } else { + candidateJurors = await prisma.user.findMany({ + where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, + select: { id: true, name: true, email: true, maxAssignments: true }, + }) + } + + // Filter out already assigned and COI jurors + const eligible = candidateJurors.filter( + (j) => !alreadyAssignedIds.has(j.id) && !coiUserIds.has(j.id) + ) + + if (eligible.length === 0) return null + + // Get current assignment counts for eligible jurors in this round + const counts = await prisma.assignment.groupBy({ + by: ['userId'], + where: { roundId, userId: { in: eligible.map((j) => j.id) } }, + _count: true, + }) + const countMap = new Map(counts.map((c) => [c.userId, c._count])) + + // Find jurors under their limit, sorted by fewest assignments (load balancing) + const underLimit = eligible + .map((j) => ({ + ...j, + currentCount: countMap.get(j.id) || 0, + effectiveMax: j.maxAssignments ?? maxAssignmentsPerJuror, + })) + .filter((j) => j.currentCount < j.effectiveMax) + .sort((a, b) => a.currentCount - b.currentCount) + + if (underLimit.length === 0) return null + + const replacement = underLimit[0] + + // Delete old assignment (cascade deletes COI record and any draft evaluation) + await prisma.assignment.delete({ where: { id: params.assignmentId } }) + + // Create new assignment + const newAssignment = await prisma.assignment.create({ + data: { + userId: replacement.id, + projectId, + roundId, + method: 'MANUAL', + }, + }) + + // Notify the replacement juror + await createNotification({ + userId: replacement.id, + type: NotificationTypes.ASSIGNED_TO_PROJECT, + title: 'New Project Assigned', + message: `You have been assigned to evaluate "${assignment.project.title}" for ${assignment.round.name}.`, + linkUrl: `/jury/competitions`, + linkLabel: 'View Assignment', + metadata: { projectId, roundName: assignment.round.name }, + }) + + // Notify admins of the reassignment + await notifyAdmins({ + type: NotificationTypes.BATCH_ASSIGNED, + title: 'COI Auto-Reassignment', + message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`, + linkUrl: `/admin/rounds/${roundId}`, + metadata: { + projectId, + oldJurorId: assignment.userId, + newJurorId: replacement.id, + reason: 'COI', + }, + }) + + // Audit + if (params.auditUserId) { + await logAudit({ + prisma, + userId: params.auditUserId, + action: 'COI_REASSIGNMENT', + entityType: 'Assignment', + entityId: newAssignment.id, + detailsJson: { + oldAssignmentId: params.assignmentId, + oldJurorId: assignment.userId, + newJurorId: replacement.id, + projectId, + roundId, + }, + ipAddress: params.auditIp, + userAgent: params.auditUserAgent, + }) + } + + return { + newJurorId: replacement.id, + newJurorName: replacement.name || replacement.email, + newAssignmentId: newAssignment.id, + } +} + async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) { try { await prisma.assignmentJob.update({ @@ -240,6 +395,7 @@ export const assignmentRouter = router({ user: { select: { id: true, name: true, email: true, expertiseTags: true } }, project: { select: { id: true, title: true, tags: true } }, evaluation: { select: { status: true, submittedAt: true } }, + conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } }, }, orderBy: { createdAt: 'desc' }, }) @@ -1520,4 +1676,24 @@ export const assignmentRouter = router({ return { sent: 1, projectCount } }), + + reassignCOI: adminProcedure + .input(z.object({ assignmentId: z.string() })) + .mutation(async ({ ctx, input }) => { + const result = await reassignAfterCOI({ + assignmentId: input.assignmentId, + auditUserId: ctx.user.id, + auditIp: ctx.ip, + auditUserAgent: ctx.userAgent, + }) + + if (!result) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No eligible juror found for reassignment. All jurors are either already assigned to this project, have a COI, or are at their assignment limit.', + }) + } + + return result + }), }) diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index f78b7a1..31fe761 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' +import { reassignAfterCOI } from './assignment' import { sendManualReminders } from '../services/evaluation-reminders' import { generateSummary } from '@/server/services/ai-evaluation-summary' @@ -536,7 +537,23 @@ export const evaluationRouter = router({ userAgent: ctx.userAgent, }) - return coi + // Auto-reassign the project to another eligible juror + let reassignment: { newJurorId: string; newJurorName: string } | null = null + if (input.hasConflict) { + try { + reassignment = await reassignAfterCOI({ + assignmentId: input.assignmentId, + auditUserId: ctx.user.id, + auditIp: ctx.ip, + auditUserAgent: ctx.userAgent, + }) + } catch (err) { + // Don't fail the COI declaration if reassignment fails + console.error('[COI] Auto-reassignment failed:', err) + } + } + + return { ...coi, reassignment } }), /** @@ -599,6 +616,17 @@ export const evaluationRouter = router({ }, }) + // If admin chose "reassigned", trigger actual reassignment + let reassignment: { newJurorId: string; newJurorName: string } | null = null + if (input.reviewAction === 'reassigned') { + reassignment = await reassignAfterCOI({ + assignmentId: coi.assignmentId, + auditUserId: ctx.user.id, + auditIp: ctx.ip, + auditUserAgent: ctx.userAgent, + }) + } + // Audit log await logAudit({ prisma: ctx.prisma, @@ -611,12 +639,13 @@ export const evaluationRouter = router({ assignmentId: coi.assignmentId, userId: coi.userId, projectId: coi.projectId, + reassignedTo: reassignment?.newJurorId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - return coi + return { ...coi, reassignment } }), // =========================================================================