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

- 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:
2026-02-23 16:08:46 +01:00
parent 49e9405e01
commit 95d51e7de3
7 changed files with 570 additions and 15 deletions

View File

@@ -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,
}
})

View File

@@ -33,6 +33,7 @@ export const NotificationTypes = {
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
COI_REASSIGNED: 'COI_REASSIGNED',
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
DROPOUT_REASSIGNED: 'DROPOUT_REASSIGNED',
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
PROJECT_UPDATED: 'PROJECT_UPDATED',
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
@@ -104,6 +105,7 @@ export const NotificationIcons: Record<string, string> = {
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
[NotificationTypes.DROPOUT_REASSIGNED]: 'UserMinus',
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
[NotificationTypes.REMINDER_24H]: 'Clock',
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
@@ -131,6 +133,7 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
[NotificationTypes.COI_REASSIGNED]: 'high',
[NotificationTypes.MANUAL_REASSIGNED]: 'high',
[NotificationTypes.DROPOUT_REASSIGNED]: 'high',
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
[NotificationTypes.DEADLINE_24H]: 'high',
[NotificationTypes.REMINDER_24H]: 'high',