Auto-reassign projects when juror declares conflict of interest
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
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:
@@ -2411,7 +2411,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
|
||||
>
|
||||
@@ -2671,6 +2671,17 @@ function IndividualAssignmentsTable({
|
||||
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({
|
||||
onSuccess: () => {
|
||||
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 text-muted-foreground">{a.project?.title || 'Unknown'}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
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',
|
||||
<div className="flex items-center gap-1">
|
||||
{a.conflictOfInterest?.hasConflict ? (
|
||||
<Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
|
||||
COI
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
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>
|
||||
)}
|
||||
>
|
||||
{a.evaluation?.status || 'PENDING'}
|
||||
</Badge>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
@@ -2895,6 +2914,18 @@ function IndividualAssignmentsTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
@@ -3988,9 +4019,15 @@ function COIReviewSection({ roundId }: { roundId: string }) {
|
||||
)
|
||||
|
||||
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
|
||||
onSuccess: () => {
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -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<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) {
|
||||
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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user