Auto-reassign projects when juror declares conflict of interest
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s

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 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-19 18:30:01 +01:00
parent 1dcc7a5990
commit 0ff84686f0
3 changed files with 259 additions and 17 deletions

View File

@@ -2411,7 +2411,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity" className="h-5 w-5 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending} disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })} onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
> >
@@ -2671,6 +2671,17 @@ function IndividualAssignmentsTable({
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.evaluation.listCOIByStage.invalidate({ roundId })
toast.success(`Reassigned to ${data.newJurorName}`)
},
onError: (err) => toast.error(err.message),
})
const createMutation = trpc.assignment.create.useMutation({ const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId }) utils.assignment.listByStage.invalidate({ roundId })
@@ -2875,19 +2886,27 @@ function IndividualAssignmentsTable({
> >
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span> <span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span> <span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
<Badge <div className="flex items-center gap-1">
variant="outline" {a.conflictOfInterest?.hasConflict ? (
className={cn( <Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
'text-[10px] justify-center', COI
a.evaluation?.status === 'SUBMITTED' </Badge>
? 'bg-emerald-50 text-emerald-700 border-emerald-200' ) : (
: a.evaluation?.status === 'DRAFT' <Badge
? 'bg-blue-50 text-blue-700 border-blue-200' variant="outline"
: 'bg-gray-50 text-gray-600 border-gray-200', className={cn(
'text-[10px] justify-center',
a.evaluation?.status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a.evaluation?.status === 'DRAFT'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
)}
>
{a.evaluation?.status || 'PENDING'}
</Badge>
)} )}
> </div>
{a.evaluation?.status || 'PENDING'}
</Badge>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon" className="h-7 w-7">
@@ -2895,6 +2914,18 @@ function IndividualAssignmentsTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{a.conflictOfInterest?.hasConflict && (
<>
<DropdownMenuItem
onClick={() => reassignCOIMutation.mutate({ assignmentId: a.id })}
disabled={reassignCOIMutation.isPending}
>
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
Reassign (COI)
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{a.evaluation && ( {a.evaluation && (
<> <>
<DropdownMenuItem <DropdownMenuItem
@@ -3988,9 +4019,15 @@ function COIReviewSection({ roundId }: { roundId: string }) {
) )
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({ const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
onSuccess: () => { onSuccess: (data) => {
utils.evaluation.listCOIByStage.invalidate({ roundId }) 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), onError: (err) => toast.error(err.message),
}) })

View File

@@ -17,6 +17,161 @@ import {
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit' 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<string, unknown>
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) { async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
try { try {
await prisma.assignmentJob.update({ await prisma.assignmentJob.update({
@@ -240,6 +395,7 @@ export const assignmentRouter = router({
user: { select: { id: true, name: true, email: true, expertiseTags: true } }, user: { select: { id: true, name: true, email: true, expertiseTags: true } },
project: { select: { id: true, title: true, tags: true } }, project: { select: { id: true, title: true, tags: true } },
evaluation: { select: { status: true, submittedAt: true } }, evaluation: { select: { status: true, submittedAt: true } },
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
@@ -1520,4 +1676,24 @@ export const assignmentRouter = router({
return { sent: 1, projectCount } 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
}),
}) })

View File

@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { reassignAfterCOI } from './assignment'
import { sendManualReminders } from '../services/evaluation-reminders' import { sendManualReminders } from '../services/evaluation-reminders'
import { generateSummary } from '@/server/services/ai-evaluation-summary' import { generateSummary } from '@/server/services/ai-evaluation-summary'
@@ -536,7 +537,23 @@ export const evaluationRouter = router({
userAgent: ctx.userAgent, 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 // Audit log
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
@@ -611,12 +639,13 @@ export const evaluationRouter = router({
assignmentId: coi.assignmentId, assignmentId: coi.assignmentId,
userId: coi.userId, userId: coi.userId,
projectId: coi.projectId, projectId: coi.projectId,
reassignedTo: reassignment?.newJurorId,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
return coi return { ...coi, reassignment }
}), }),
// ========================================================================= // =========================================================================