2026-03-07 16:18:24 +01:00
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 < string , unknown >
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 < 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 ───────────────────────────────────────────────
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 < 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 ) = > {
// 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 < string , unknown >
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 < 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 {
// 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 < string , number > ( )
for ( const a of existingAssignments ) {
currentLoads . set ( a . userId , ( currentLoads . get ( a . userId ) ? ? 0 ) + 1 )
}
const coiRecords = await prisma . conflictOfInterest . findMany ( {
where : {
refactor: tech debt batch 2 — drop dead models, stale columns, schema cleanup
Schema:
- Drop 4 dead models: OverrideAction, NotificationPolicy, AssignmentException, AdvancementRule
- Drop 2 dead enums: OverrideReasonCode, AdvancementRuleType
- Drop 3 stale columns: Project.roundId, ConflictOfInterest.roundId, Evaluation.version
- Remove 3 back-relation fields from User, Assignment, Round
Code:
- Fix 6 COI queries in assignment.ts + 1 in juror-reassignment.ts
(roundId filter → assignment.roundId after column drop)
- Remove orphaned Project.roundId write in project.ts createProject
- Remove advancementRules include from round.ts getById
- Remove AdvancementRule from RoundWithRelations type
- Clean up seed.ts (remove advancement rule seeding)
- Clean up tests/helpers.ts (remove dead model cleanup)
- Add TODO comments on user delete mutations (FK violation risk)
Migration: 20260308000000_drop_dead_models_and_stale_columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:35:23 +01:00
assignment : { roundId : params.roundId } ,
2026-03-07 16:18:24 +01:00
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 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 < string , number > = { }
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 < 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 )
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 ,
}
}