Add COI/manual reassignment emails, confirmation dialog, and smart juror selection
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
- Add COI_REASSIGNED and MANUAL_REASSIGNED notification types with distinct email templates, icons, and priorities - COI declaration dialog now shows a confirmation step warning that the project will be reassigned before submitting - reassignAfterCOI now checks historical assignments (all rounds, audit logs) to never assign the same project to a juror twice, and prefers jurors with incomplete evaluations over those who have finished all their work - Admin transfer (transferAssignments) sends per-juror MANUAL_REASSIGNED notifications with actual project names instead of generic batch emails - docker-entrypoint syncs notification settings on every deploy via upsert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,21 +46,51 @@ export async function reassignAfterCOI(params: {
|
||||
(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 },
|
||||
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
||||
|
||||
// 1. Currently assigned to this project in ANY round (not just current)
|
||||
const allProjectAssignments = await prisma.assignment.findMany({
|
||||
where: { projectId },
|
||||
select: { userId: true },
|
||||
})
|
||||
const alreadyAssignedIds = new Set(existingAssignments.map((a) => a.userId))
|
||||
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
||||
|
||||
// Get all COI records for this project (any juror who declared conflict)
|
||||
// 2. COI records for this project (any juror who declared conflict, ever)
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: { projectId, hasConflict: true },
|
||||
select: { userId: true },
|
||||
})
|
||||
const coiUserIds = new Set(coiRecords.map((c) => c.userId))
|
||||
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
||||
|
||||
// 3. Historical: jurors who previously had this project but were removed
|
||||
// (via COI reassignment or admin transfer — tracked in audit logs)
|
||||
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
||||
where: {
|
||||
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
||||
detailsJson: { path: ['projectId'], equals: projectId },
|
||||
},
|
||||
select: { detailsJson: true },
|
||||
})
|
||||
for (const log of historicalAuditLogs) {
|
||||
const details = log.detailsJson as Record<string, unknown> | null
|
||||
if (!details) continue
|
||||
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
||||
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
||||
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
||||
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
||||
// Transfer logs may have a moves array with per-project details
|
||||
if (Array.isArray(details.moves)) {
|
||||
for (const move of details.moves as Array<Record<string, unknown>>) {
|
||||
if (move.projectId === projectId && move.newJurorId) {
|
||||
// The juror who received via past transfer also had it
|
||||
excludedUserIds.add(move.newJurorId as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find candidate jurors ───────────────────────────────────────────────
|
||||
|
||||
// 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) {
|
||||
@@ -92,34 +122,51 @@ export async function reassignAfterCOI(params: {
|
||||
: []
|
||||
}
|
||||
|
||||
// Filter out already assigned and COI jurors
|
||||
const eligible = candidateJurors.filter(
|
||||
(j) => !alreadyAssignedIds.has(j.id) && !coiUserIds.has(j.id)
|
||||
)
|
||||
// Filter out all excluded jurors (current assignments, COI, historical)
|
||||
const eligible = candidateJurors.filter((j) => !excludedUserIds.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,
|
||||
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
||||
|
||||
const eligibleIds = eligible.map((j) => j.id)
|
||||
|
||||
// Get assignment counts and evaluation completion for eligible jurors in this round
|
||||
const roundAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId, userId: { in: eligibleIds } },
|
||||
select: { userId: true, evaluation: { select: { status: 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,
|
||||
}))
|
||||
// Build per-juror stats: total assignments, completed evaluations
|
||||
const jurorStats = new Map<string, { total: number; completed: number }>()
|
||||
for (const a of roundAssignments) {
|
||||
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
||||
stats.total++
|
||||
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
||||
stats.completed++
|
||||
}
|
||||
jurorStats.set(a.userId, stats)
|
||||
}
|
||||
|
||||
// Rank jurors: under cap, then prefer those still working (completed < total)
|
||||
const ranked = eligible
|
||||
.map((j) => {
|
||||
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
||||
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||
const hasIncomplete = stats.completed < stats.total
|
||||
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
||||
})
|
||||
.filter((j) => j.currentCount < j.effectiveMax)
|
||||
.sort((a, b) => a.currentCount - b.currentCount)
|
||||
.sort((a, b) => {
|
||||
// 1. Prefer jurors with incomplete evaluations (still active)
|
||||
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
||||
// 2. Then fewest current assignments (load balancing)
|
||||
return a.currentCount - b.currentCount
|
||||
})
|
||||
|
||||
if (underLimit.length === 0) return null
|
||||
if (ranked.length === 0) return null
|
||||
|
||||
const replacement = underLimit[0]
|
||||
const replacement = ranked[0]
|
||||
|
||||
// Delete old assignment and create replacement atomically.
|
||||
// Cascade deletes COI record and any draft evaluation.
|
||||
@@ -137,15 +184,15 @@ export async function reassignAfterCOI(params: {
|
||||
})
|
||||
})
|
||||
|
||||
// Notify the replacement juror
|
||||
// Notify the replacement juror (COI-specific notification)
|
||||
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}.`,
|
||||
type: NotificationTypes.COI_REASSIGNED,
|
||||
title: 'Project Reassigned to You (COI)',
|
||||
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignment',
|
||||
metadata: { projectId, roundName: assignment.round.name },
|
||||
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
||||
})
|
||||
|
||||
// Notify admins of the reassignment
|
||||
@@ -2397,22 +2444,28 @@ export const assignmentRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Notify destination jurors
|
||||
// Notify destination jurors with per-juror project names
|
||||
if (actualMoves.length > 0) {
|
||||
const destCounts: Record<string, number> = {}
|
||||
const destMoves: Record<string, string[]> = {}
|
||||
for (const move of actualMoves) {
|
||||
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
|
||||
if (!destMoves[move.destinationJurorId]) destMoves[move.destinationJurorId] = []
|
||||
destMoves[move.destinationJurorId].push(move.projectTitle)
|
||||
}
|
||||
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(destCounts),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments via transfer in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'assignment_transfer' },
|
||||
})
|
||||
for (const [jurorId, projectNames] of Object.entries(destMoves)) {
|
||||
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, reason: 'admin_transfer' },
|
||||
})
|
||||
}
|
||||
|
||||
// Notify admins
|
||||
const sourceJuror = await ctx.prisma.user.findUnique({
|
||||
@@ -2421,10 +2474,10 @@ export const assignmentRouter = router({
|
||||
})
|
||||
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
|
||||
|
||||
const topReceivers = Object.entries(destCounts)
|
||||
.map(([jurorId, count]) => {
|
||||
const topReceivers = Object.entries(destMoves)
|
||||
.map(([jurorId, projects]) => {
|
||||
const u = destUserMap.get(jurorId)
|
||||
return `${u?.name || u?.email || jurorId} (${count})`
|
||||
return `${u?.name || u?.email || jurorId} (${projects.length})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export const NotificationTypes = {
|
||||
|
||||
// Jury notifications
|
||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||
COI_REASSIGNED: 'COI_REASSIGNED',
|
||||
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
|
||||
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
||||
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
||||
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
||||
@@ -100,6 +102,8 @@ export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
|
||||
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
|
||||
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
||||
[NotificationTypes.REMINDER_24H]: 'Clock',
|
||||
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
||||
@@ -125,6 +129,8 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.REMINDER_1H]: 'urgent',
|
||||
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||
[NotificationTypes.COI_REASSIGNED]: 'high',
|
||||
[NotificationTypes.MANUAL_REASSIGNED]: 'high',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
||||
[NotificationTypes.DEADLINE_24H]: 'high',
|
||||
[NotificationTypes.REMINDER_24H]: 'high',
|
||||
|
||||
Reference in New Issue
Block a user