import { TRPCError } from '@trpc/server' import { prisma } from '@/lib/prisma' import { createNotification, notifyAdmins, NotificationTypes, } from './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 const maxAssignmentsPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // ── 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 excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId)) // 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 }, }) 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 | 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>) { 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 ─────────────────────────────────────────────── 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 { // No jury group — scope to jurors already assigned to this round const roundJurorIds = await prisma.assignment.findMany({ where: { roundId }, select: { userId: true }, distinct: ['userId'], }) const activeRoundJurorIds = roundJurorIds.map((a) => a.userId) candidateJurors = activeRoundJurorIds.length > 0 ? await prisma.user.findMany({ where: { id: { in: activeRoundJurorIds }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE', }, select: { id: true, name: true, email: true, maxAssignments: true }, }) : [] } // Filter out all excluded jurors (current assignments, COI, historical) const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id)) if (eligible.length === 0) return null // ── 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 } } }, }) // Build per-juror stats: total assignments, completed evaluations const jurorStats = new Map() 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) => { // 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 (ranked.length === 0) return null const replacement = ranked[0] // Delete old assignment and create replacement atomically. // Cascade deletes COI record and any draft evaluation. const newAssignment = await prisma.$transaction(async (tx) => { await tx.assignment.delete({ where: { id: params.assignmentId } }) return tx.assignment.create({ data: { userId: replacement.id, projectId, roundId, juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined, isRequired: assignment.isRequired, method: 'MANUAL', }, }) }) // Notify the replacement juror (COI-specific notification) await createNotification({ userId: replacement.id, 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, projectName: assignment.project.title, roundName: assignment.round.name }, }) // Notify admins of the reassignment await notifyAdmins({ type: NotificationTypes.EVALUATION_MILESTONE, 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}`, linkLabel: 'View Round', 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, } } /** Evaluation statuses that are safe to move (not yet finalized). */ const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const export 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 const fallbackCap = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // Only pick assignments with no evaluation or evaluation still in draft/not-started. // Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched. const assignmentsToMove = await prisma.assignment.findMany({ where: { roundId: params.roundId, userId: params.droppedJurorId, OR: [ { evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, select: { id: true, projectId: true, juryGroupId: true, isRequired: true, createdAt: true, project: { select: { title: true } }, }, orderBy: { createdAt: 'asc' }, }) if (assignmentsToMove.length === 0) { return { movedCount: 0, failedCount: 0, failedProjects: [] as string[], reassignedTo: {} as Record, } } 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 { // No jury group configured — scope to jurors already assigned to this round // (the de facto jury pool). This prevents assigning to random JURY_MEMBER // accounts that aren't part of this round's jury. const roundJurorIds = await prisma.assignment.findMany({ where: { roundId: params.roundId }, select: { userId: true }, distinct: ['userId'], }) const activeRoundJurorIds = roundJurorIds .map((a) => a.userId) .filter((id) => id !== params.droppedJurorId) candidateJurors = activeRoundJurorIds.length > 0 ? await prisma.user.findMany({ where: { id: { in: activeRoundJurorIds }, roles: { has: '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 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() for (const a of existingAssignments) { currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1) } const coiRecords = await prisma.conflictOfInterest.findMany({ where: { assignment: { 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() for (const juror of candidateJurors) { caps.set(juror.id, juror.maxAssignments ?? fallbackCap) } const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j])) const plannedMoves: { assignmentId: string projectId: string projectTitle: string newJurorId: string juryGroupId: string | null isRequired: boolean }[] = [] 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] plannedMoves.push({ assignmentId: assignment.id, projectId: assignment.projectId, projectTitle: assignment.project.title, newJurorId: selectedJurorId, juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired, }) alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`) currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1) } // Execute moves inside a transaction with per-move TOCTOU guard. // Uses conditional deleteMany so a concurrent evaluation submission // (which sets status to SUBMITTED) causes the delete to return count=0 // instead of cascade-destroying the submitted evaluation. const actualMoves: typeof plannedMoves = [] const skippedProjects: string[] = [] if (plannedMoves.length > 0) { await prisma.$transaction(async (tx) => { for (const move of plannedMoves) { // Guard: only delete if the assignment still belongs to the dropped juror // AND its evaluation (if any) is still in a movable state. // If a juror submitted between our read and now, count will be 0. const deleted = await tx.assignment.deleteMany({ where: { id: move.assignmentId, userId: params.droppedJurorId, OR: [ { evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, }) if (deleted.count === 0) { // Assignment was already moved, deleted, or its evaluation was submitted skippedProjects.push(move.projectTitle) continue } await tx.assignment.create({ data: { roundId: params.roundId, projectId: move.projectId, userId: move.newJurorId, juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired, method: 'MANUAL', createdBy: params.auditUserId ?? undefined, }, }) actualMoves.push(move) } }) } // Add skipped projects to the failed list failedProjects.push(...skippedProjects) const reassignedTo: Record = {} for (const move of actualMoves) { reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1 } if (actualMoves.length > 0) { // Build per-juror project name lists for proper emails const destProjectNames: Record = {} 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) return `${juror?.name || juror?.email || jurorId} (${count})` }) .join(', ') await notifyAdmins({ type: NotificationTypes.EVALUATION_MILESTONE, title: 'Juror Dropout Reshuffle', message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`, linkUrl: `/admin/rounds/${round.id}`, linkLabel: 'View Round', metadata: { roundId: round.id, droppedJurorId: droppedJuror.id, movedCount: actualMoves.length, failedCount: failedProjects.length, topReceivers, }, }) } // Remove the dropped juror from the jury group so they can't be re-assigned // in future assignment runs for this round's competition. let removedFromGroup = false if (round.juryGroupId) { const deleted = await prisma.juryGroupMember.deleteMany({ where: { juryGroupId: round.juryGroupId, userId: params.droppedJurorId, }, }) removedFromGroup = deleted.count > 0 } if (params.auditUserId) { // Build per-project move detail for audit trail const moveDetails = actualMoves.map((move) => { const juror = candidateMeta.get(move.newJurorId) return { projectId: move.projectId, projectTitle: move.projectTitle, newJurorId: move.newJurorId, newJurorName: juror?.name || juror?.email || move.newJurorId, } }) 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: actualMoves.length, failedCount: failedProjects.length, failedProjects, skippedProjects, reassignedTo, removedFromGroup, moves: moveDetails, }, ipAddress: params.auditIp, userAgent: params.auditUserAgent, }) } return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects, reassignedTo, } }