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:
@@ -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