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
|
<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,6 +2886,12 @@ 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>
|
||||||
|
<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
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -2888,6 +2905,8 @@ function IndividualAssignmentsTable({
|
|||||||
>
|
>
|
||||||
{a.evaluation?.status || 'PENDING'}
|
{a.evaluation?.status || 'PENDING'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<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 })
|
||||||
|
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')
|
toast.success('COI review updated')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user