Add juror quick actions to Members section, redistribute button, dropout emails, and transfer duplicate detection
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Add mail/transfer/reshuffle/redistribute icons to each juror row in Members card - New redistributeJurorAssignments procedure: reassign all pending projects without dropping juror from group - New DROPOUT_REASSIGNED email template with project names, deadline, and dropped juror context - Update reassignDroppedJuror to send per-juror DROPOUT_REASSIGNED emails instead of generic BATCH_ASSIGNED - Transfer dialog now shows all candidates with "Already assigned" / "At cap" labels instead of hiding them - SQL script for prod DB insertion of new notification setting without seeding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -476,17 +476,39 @@ async function reassignDroppedJurorAssignments(params: {
|
||||
}
|
||||
|
||||
if (actualMoves.length > 0) {
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(reassignedTo),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
||||
})
|
||||
// Build per-juror project name lists for proper emails
|
||||
const destProjectNames: Record<string, string[]> = {}
|
||||
for (const move of actualMoves) {
|
||||
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||
}
|
||||
|
||||
const droppedName = droppedJuror.name || droppedJuror.email
|
||||
|
||||
// Fetch round deadline for email
|
||||
const roundFull = await prisma.round.findUnique({
|
||||
where: { id: params.roundId },
|
||||
select: { windowCloseAt: true },
|
||||
})
|
||||
const deadline = roundFull?.windowCloseAt
|
||||
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
||||
: undefined
|
||||
|
||||
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||
const count = projectNames.length
|
||||
await createNotification({
|
||||
userId: jurorId,
|
||||
type: NotificationTypes.DROPOUT_REASSIGNED,
|
||||
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||
message: count === 1
|
||||
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
||||
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
||||
})
|
||||
}
|
||||
|
||||
const topReceivers = Object.entries(reassignedTo)
|
||||
.map(([jurorId, count]) => {
|
||||
const juror = candidateMeta.get(jurorId)
|
||||
@@ -2116,6 +2138,218 @@ export const assignmentRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Redistribute all movable assignments from a juror to other jurors (without dropping them from the group).
|
||||
* Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group.
|
||||
* Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors.
|
||||
*/
|
||||
redistributeJurorAssignments: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true },
|
||||
})
|
||||
|
||||
const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.jurorId },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
const assignmentsToMove = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
userId: input.jurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true, projectId: true, juryGroupId: true, isRequired: true,
|
||||
project: { select: { title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (assignmentsToMove.length === 0) {
|
||||
return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] }
|
||||
}
|
||||
|
||||
// Build candidate pool
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
if (round.juryGroupId) {
|
||||
const members = await ctx.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 !== input.jurorId).map((m) => m.user)
|
||||
} else {
|
||||
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
||||
candidateJurors = ids.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||
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 ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.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 ctx.prisma.conflictOfInterest.findMany({
|
||||
where: { roundId: input.roundId, hasConflict: true, userId: { in: candidateIds } },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
// Completed eval counts for "prefer not-finished" logic
|
||||
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||
where: { assignment: { roundId: input.roundId, userId: { in: candidateIds } }, status: 'SUBMITTED' },
|
||||
select: { assignment: { select: { userId: true } } },
|
||||
})
|
||||
const completedCounts = new Map<string, number>()
|
||||
for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
|
||||
|
||||
const caps = new Map<string, number>()
|
||||
for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap)
|
||||
|
||||
const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = []
|
||||
const failedProjects: string[] = []
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
// First pass: prefer jurors who haven't completed all evals
|
||||
let eligible = candidateIds
|
||||
.filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`))
|
||||
.filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`))
|
||||
.filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap))
|
||||
|
||||
// Sort: prefer not-all-completed, then lowest load
|
||||
eligible.sort((a, b) => {
|
||||
const loadA = currentLoads.get(a) ?? 0
|
||||
const loadB = currentLoads.get(b) ?? 0
|
||||
const compA = completedCounts.get(a) ?? 0
|
||||
const compB = completedCounts.get(b) ?? 0
|
||||
const doneA = loadA > 0 && compA === loadA ? 1 : 0
|
||||
const doneB = loadB > 0 && compB === loadB ? 1 : 0
|
||||
if (doneA !== doneB) return doneA - doneB
|
||||
return loadA - loadB
|
||||
})
|
||||
|
||||
if (eligible.length === 0) {
|
||||
failedProjects.push(assignment.project.title)
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedId = eligible[0]
|
||||
plannedMoves.push({
|
||||
assignmentId: assignment.id, projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title, newJurorId: selectedId,
|
||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired,
|
||||
})
|
||||
alreadyAssigned.add(`${selectedId}:${assignment.projectId}`)
|
||||
currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Execute in transaction
|
||||
const actualMoves: typeof plannedMoves = []
|
||||
if (plannedMoves.length > 0) {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const move of plannedMoves) {
|
||||
const deleted = await tx.assignment.deleteMany({
|
||||
where: {
|
||||
id: move.assignmentId, userId: input.jurorId,
|
||||
OR: [{ evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }],
|
||||
},
|
||||
})
|
||||
if (deleted.count === 0) { failedProjects.push(move.projectTitle); continue }
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
roundId: input.roundId, projectId: move.projectId, userId: move.newJurorId,
|
||||
juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired,
|
||||
method: 'MANUAL', createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
actualMoves.push(move)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Send MANUAL_REASSIGNED emails per destination juror
|
||||
if (actualMoves.length > 0) {
|
||||
const destProjectNames: Record<string, string[]> = {}
|
||||
for (const move of actualMoves) {
|
||||
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||
}
|
||||
|
||||
const deadline = round.windowCloseAt
|
||||
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt)
|
||||
: undefined
|
||||
|
||||
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||
const count = projectNames.length
|
||||
await createNotification({
|
||||
userId: jurorId,
|
||||
type: NotificationTypes.MANUAL_REASSIGNED,
|
||||
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||
message: count === 1
|
||||
? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.`
|
||||
: `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' },
|
||||
})
|
||||
}
|
||||
|
||||
const sourceName = sourceJuror.name || sourceJuror.email
|
||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||
const topReceivers = Object.entries(destProjectNames)
|
||||
.map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` })
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||
title: 'Assignment Redistribution',
|
||||
message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
linkLabel: 'View Round',
|
||||
metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE',
|
||||
entityType: 'Round', entityId: round.id,
|
||||
detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
||||
ipAddress: ctx.ip, userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get transfer candidates: which of the source juror's assignments can be moved,
|
||||
* and which other jurors are eligible to receive them.
|
||||
@@ -2253,6 +2487,11 @@ export const assignmentRouter = router({
|
||||
load < cap
|
||||
)
|
||||
|
||||
// Track which movable projects this candidate already has assigned
|
||||
const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
|
||||
alreadyAssigned.has(`${j.id}:${pid}`)
|
||||
)
|
||||
|
||||
return {
|
||||
userId: j.id,
|
||||
name: j.name || j.email,
|
||||
@@ -2261,6 +2500,7 @@ export const assignmentRouter = router({
|
||||
cap,
|
||||
allCompleted,
|
||||
eligibleProjectIds,
|
||||
alreadyAssignedProjectIds,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user