feat(assignments): reshuffle dropped juror projects within caps
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
This commit is contained in:
@@ -172,6 +172,241 @@ export async function reassignAfterCOI(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function reassignDroppedJurorAssignments(params: {
|
||||
roundId: string
|
||||
droppedJurorId: string
|
||||
auditUserId?: string
|
||||
auditIp?: string
|
||||
auditUserAgent?: string
|
||||
}) {
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: params.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||
}
|
||||
|
||||
const droppedJuror = await prisma.user.findUnique({
|
||||
where: { id: params.droppedJurorId },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
if (!droppedJuror) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
|
||||
}
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const assignmentsToMove = await prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: params.roundId,
|
||||
userId: params.droppedJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: ['PENDING', 'DRAFT'] } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
evaluation: { select: { id: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (assignmentsToMove.length === 0) {
|
||||
return {
|
||||
movedCount: 0,
|
||||
failedCount: 0,
|
||||
failedProjects: [] as string[],
|
||||
reassignedTo: {} as Record<string, number>,
|
||||
}
|
||||
}
|
||||
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
maxAssignments: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
candidateJurors = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
id: { not: params.droppedJurorId },
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (candidateJurors.length === 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId: params.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: params.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
const caps = new Map<string, number>()
|
||||
for (const juror of candidateJurors) {
|
||||
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
||||
}
|
||||
|
||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||
const moves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string }[] = []
|
||||
const failedProjects: string[] = []
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
const eligible = candidateIds
|
||||
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
||||
.sort((a, b) => {
|
||||
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
||||
if (loadDiff !== 0) return loadDiff
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
if (eligible.length === 0) {
|
||||
failedProjects.push(assignment.project.title)
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedJurorId = eligible[0]
|
||||
moves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
newJurorId: selectedJurorId,
|
||||
})
|
||||
|
||||
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
if (moves.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const move of moves) {
|
||||
await tx.assignment.delete({ where: { id: move.assignmentId } })
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
roundId: params.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.newJurorId,
|
||||
method: 'MANUAL',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const reassignedTo: Record<string, number> = {}
|
||||
for (const move of moves) {
|
||||
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
if (moves.length > 0) {
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(reassignedTo),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignment${Object.keys(reassignedTo).length > 1 ? 's' : ''} due to a jury reassignment in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
||||
})
|
||||
|
||||
const droppedName = droppedJuror.name || droppedJuror.email
|
||||
const topReceivers = Object.entries(reassignedTo)
|
||||
.map(([jurorId, count]) => {
|
||||
const juror = candidateMeta.get(jurorId)
|
||||
return `${juror?.name || juror?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Juror Dropout Reshuffle',
|
||||
message: `Reassigned ${moves.length} project(s) from ${droppedName}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned.'}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
droppedJurorId: droppedJuror.id,
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
topReceivers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (params.auditUserId) {
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: params.auditUserId,
|
||||
action: 'JUROR_DROPOUT_RESHUFFLE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
droppedJurorId: droppedJuror.id,
|
||||
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
reassignedTo,
|
||||
},
|
||||
ipAddress: params.auditIp,
|
||||
userAgent: params.auditUserAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
movedCount: moves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
reassignedTo,
|
||||
}
|
||||
}
|
||||
|
||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
await prisma.assignmentJob.update({
|
||||
@@ -1696,4 +1931,16 @@ export const assignmentRouter = router({
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
reassignDroppedJuror: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reassignDroppedJurorAssignments({
|
||||
roundId: input.roundId,
|
||||
droppedJurorId: input.jurorId,
|
||||
auditUserId: ctx.user.id,
|
||||
auditIp: ctx.ip,
|
||||
auditUserAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user