2026-02-14 15:26:42 +01:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router , protectedProcedure , adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
generateAIAssignments ,
generateFallbackAssignments ,
type AssignmentProgressCallback ,
} from '../services/ai-assignment'
import { isOpenAIConfigured } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
import {
createNotification ,
createBulkNotifications ,
notifyAdmins ,
NotificationTypes ,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
2026-02-19 18:30:01 +01:00
/ * *
* 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
// Get all jurors already assigned to this project in this round
const existingAssignments = await prisma . assignment . findMany ( {
where : { roundId , projectId } ,
select : { userId : true } ,
} )
const alreadyAssignedIds = new Set ( existingAssignments . map ( ( a ) = > a . userId ) )
// Get all COI records for this project (any juror who declared conflict)
const coiRecords = await prisma . conflictOfInterest . findMany ( {
where : { projectId , hasConflict : true } ,
select : { userId : true } ,
} )
const coiUserIds = new Set ( coiRecords . map ( ( c ) = > c . userId ) )
// 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 ) {
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 {
2026-02-20 14:18:49 +01:00
// No jury group — scope to jurors already assigned to this round
const roundJurorIds = await prisma . assignment . findMany ( {
where : { roundId } ,
select : { userId : true } ,
distinct : [ 'userId' ] ,
2026-02-19 18:30:01 +01:00
} )
2026-02-20 14:18:49 +01:00
const activeRoundJurorIds = roundJurorIds . map ( ( a ) = > a . userId )
candidateJurors = activeRoundJurorIds . length > 0
? await prisma . user . findMany ( {
where : {
id : { in : activeRoundJurorIds } ,
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
} ,
select : { id : true , name : true , email : true , maxAssignments : true } ,
} )
: [ ]
2026-02-19 18:30:01 +01:00
}
// Filter out already assigned and COI jurors
const eligible = candidateJurors . filter (
( j ) = > ! alreadyAssignedIds . has ( j . id ) && ! coiUserIds . 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 ,
} )
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 ,
} ) )
. filter ( ( j ) = > j . currentCount < j . effectiveMax )
. sort ( ( a , b ) = > a . currentCount - b . currentCount )
if ( underLimit . length === 0 ) return null
const replacement = underLimit [ 0 ]
2026-02-20 03:48:17 +01:00
// 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' ,
} ,
} )
2026-02-19 18:30:01 +01:00
} )
// Notify the replacement juror
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 } . ` ,
linkUrl : ` /jury/competitions ` ,
linkLabel : 'View Assignment' ,
metadata : { projectId , roundName : assignment.round.name } ,
} )
// Notify admins of the reassignment
await notifyAdmins ( {
2026-02-20 14:35:21 +01:00
type : NotificationTypes . EVALUATION_MILESTONE ,
2026-02-19 18:30:01 +01:00
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 } ` ,
2026-02-20 14:35:21 +01:00
linkLabel : 'View Round' ,
2026-02-19 18:30:01 +01:00
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 ,
}
}
2026-02-20 03:48:17 +01:00
/** Evaluation statuses that are safe to move (not yet finalized). */
const MOVABLE_EVAL_STATUSES = [ 'NOT_STARTED' , 'DRAFT' ] as const
2026-02-19 23:12:55 +01:00
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
2026-02-20 03:48:17 +01:00
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
2026-02-19 23:12:55 +01:00
const assignmentsToMove = await prisma . assignment . findMany ( {
where : {
roundId : params.roundId ,
userId : params.droppedJurorId ,
OR : [
{ evaluation : null } ,
2026-02-20 03:48:17 +01:00
{ evaluation : { status : { in : [ . . . MOVABLE_EVAL_STATUSES ] } } } ,
2026-02-19 23:12:55 +01:00
] ,
} ,
2026-02-20 00:07:30 +01:00
select : {
id : true ,
projectId : true ,
2026-02-20 03:48:17 +01:00
juryGroupId : true ,
isRequired : true ,
2026-02-20 00:07:30 +01:00
createdAt : true ,
project : { select : { title : true } } ,
2026-02-19 23:12:55 +01:00
} ,
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 {
2026-02-20 14:18:49 +01:00
// 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' ] ,
2026-02-19 23:12:55 +01:00
} )
2026-02-20 14:18:49 +01:00
const activeRoundJurorIds = roundJurorIds
. map ( ( a ) = > a . userId )
. filter ( ( id ) = > id !== params . droppedJurorId )
candidateJurors = activeRoundJurorIds . length > 0
? await prisma . user . findMany ( {
where : {
id : { in : activeRoundJurorIds } ,
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
} ,
select : { id : true , name : true , email : true , maxAssignments : true } ,
} )
: [ ]
2026-02-19 23:12:55 +01:00
}
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 : {
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 < string , number > ( )
for ( const juror of candidateJurors ) {
caps . set ( juror . id , juror . maxAssignments ? ? fallbackCap )
}
const candidateMeta = new Map ( candidateJurors . map ( ( j ) = > [ j . id , j ] ) )
2026-02-20 03:48:17 +01:00
const plannedMoves : {
assignmentId : string
projectId : string
projectTitle : string
newJurorId : string
juryGroupId : string | null
isRequired : boolean
} [ ] = [ ]
2026-02-19 23:12:55 +01:00
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 ]
2026-02-20 03:48:17 +01:00
plannedMoves . push ( {
2026-02-19 23:12:55 +01:00
assignmentId : assignment.id ,
projectId : assignment.projectId ,
projectTitle : assignment.project.title ,
newJurorId : selectedJurorId ,
2026-02-20 03:48:17 +01:00
juryGroupId : assignment.juryGroupId ? ? round . juryGroupId ,
isRequired : assignment.isRequired ,
2026-02-19 23:12:55 +01:00
} )
alreadyAssigned . add ( ` ${ selectedJurorId } : ${ assignment . projectId } ` )
currentLoads . set ( selectedJurorId , ( currentLoads . get ( selectedJurorId ) ? ? 0 ) + 1 )
}
2026-02-20 03:48:17 +01:00
// 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 ) {
2026-02-19 23:12:55 +01:00
await prisma . $transaction ( async ( tx ) = > {
2026-02-20 03:48:17 +01:00
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
}
2026-02-19 23:12:55 +01:00
await tx . assignment . create ( {
data : {
roundId : params.roundId ,
projectId : move.projectId ,
userId : move.newJurorId ,
2026-02-20 03:48:17 +01:00
juryGroupId : move.juryGroupId ? ? undefined ,
isRequired : move.isRequired ,
2026-02-19 23:12:55 +01:00
method : 'MANUAL' ,
2026-02-20 03:48:17 +01:00
createdBy : params.auditUserId ? ? undefined ,
2026-02-19 23:12:55 +01:00
} ,
} )
2026-02-20 03:48:17 +01:00
actualMoves . push ( move )
2026-02-19 23:12:55 +01:00
}
} )
}
2026-02-20 03:48:17 +01:00
// Add skipped projects to the failed list
failedProjects . push ( . . . skippedProjects )
2026-02-19 23:12:55 +01:00
const reassignedTo : Record < string , number > = { }
2026-02-20 03:48:17 +01:00
for ( const move of actualMoves ) {
2026-02-19 23:12:55 +01:00
reassignedTo [ move . newJurorId ] = ( reassignedTo [ move . newJurorId ] ? ? 0 ) + 1
}
2026-02-20 03:48:17 +01:00
if ( actualMoves . length > 0 ) {
2026-02-19 23:12:55 +01:00
await createBulkNotifications ( {
userIds : Object.keys ( reassignedTo ) ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : 'Additional Projects Assigned' ,
2026-02-20 03:48:17 +01:00
message : ` You have received additional project assignments due to a jury reassignment in ${ round . name } . ` ,
2026-02-19 23:12:55 +01:00
linkUrl : ` /jury/competitions ` ,
linkLabel : 'View Assignments' ,
metadata : { roundId : round.id , reason : 'juror_drop_reshuffle' } ,
} )
const droppedName = droppedJuror . name || droppedJuror . email
const topReceivers = Object . entries ( reassignedTo )
. map ( ( [ jurorId , count ] ) = > {
const juror = candidateMeta . get ( jurorId )
return ` ${ juror ? . name || juror ? . email || jurorId } ( ${ count } ) `
} )
. join ( ', ' )
await notifyAdmins ( {
2026-02-20 14:35:21 +01:00
type : NotificationTypes . EVALUATION_MILESTONE ,
2026-02-19 23:12:55 +01:00
title : 'Juror Dropout Reshuffle' ,
2026-02-20 14:35:21 +01:00
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.' } ` ,
2026-02-19 23:12:55 +01:00
linkUrl : ` /admin/rounds/ ${ round . id } ` ,
2026-02-20 14:35:21 +01:00
linkLabel : 'View Round' ,
2026-02-19 23:12:55 +01:00
metadata : {
roundId : round.id ,
droppedJurorId : droppedJuror.id ,
2026-02-20 03:48:17 +01:00
movedCount : actualMoves.length ,
2026-02-19 23:12:55 +01:00
failedCount : failedProjects.length ,
topReceivers ,
} ,
} )
}
2026-02-20 13:57:15 +01:00
// 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
}
2026-02-19 23:12:55 +01:00
if ( params . auditUserId ) {
2026-02-20 14:18:49 +01:00
// 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 ,
}
} )
2026-02-19 23:12:55 +01:00
await logAudit ( {
prisma ,
userId : params.auditUserId ,
action : 'JUROR_DROPOUT_RESHUFFLE' ,
entityType : 'Round' ,
entityId : round.id ,
detailsJson : {
droppedJurorId : droppedJuror.id ,
droppedJurorName : droppedJuror.name || droppedJuror . email ,
2026-02-20 03:48:17 +01:00
movedCount : actualMoves.length ,
2026-02-19 23:12:55 +01:00
failedCount : failedProjects.length ,
failedProjects ,
2026-02-20 03:48:17 +01:00
skippedProjects ,
2026-02-19 23:12:55 +01:00
reassignedTo ,
2026-02-20 13:57:15 +01:00
removedFromGroup ,
2026-02-20 14:18:49 +01:00
moves : moveDetails ,
2026-02-19 23:12:55 +01:00
} ,
ipAddress : params.auditIp ,
userAgent : params.auditUserAgent ,
} )
}
return {
2026-02-20 03:48:17 +01:00
movedCount : actualMoves.length ,
2026-02-19 23:12:55 +01:00
failedCount : failedProjects.length ,
failedProjects ,
reassignedTo ,
}
}
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
async function runAIAssignmentJob ( jobId : string , roundId : string , userId : string ) {
2026-02-14 15:26:42 +01:00
try {
await prisma . assignmentJob . update ( {
where : { id : jobId } ,
data : { status : 'RUNNING' , startedAt : new Date ( ) } ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const round = await prisma . round . findUniqueOrThrow ( {
where : { id : roundId } ,
2026-02-14 15:26:42 +01:00
select : {
name : true ,
configJson : true ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
competitionId : true ,
2026-02-19 12:59:35 +01:00
juryGroupId : true ,
2026-02-14 15:26:42 +01:00
} ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const config = ( round . configJson ? ? { } ) as Record < string , unknown >
2026-02-19 12:59:35 +01:00
const requiredReviews = ( config . requiredReviewsPerProject as number ) ? ? 3
2026-02-14 20:10:24 +01:00
const minAssignmentsPerJuror =
( config . minLoadPerJuror as number ) ? ?
( config . minAssignmentsPerJuror as number ) ? ?
1
const maxAssignmentsPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
2026-02-14 15:26:42 +01:00
2026-02-19 12:59:35 +01:00
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds : string [ ] | undefined
if ( round . juryGroupId ) {
const groupMembers = await prisma . juryGroupMember . findMany ( {
where : { juryGroupId : round.juryGroupId } ,
select : { userId : true } ,
} )
scopedJurorIds = groupMembers . map ( ( m ) = > m . userId )
}
2026-02-14 15:26:42 +01:00
const jurors = await prisma . user . findMany ( {
2026-02-19 12:59:35 +01:00
where : {
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
. . . ( scopedJurorIds ? { id : { in : scopedJurorIds } } : { } ) ,
} ,
2026-02-14 15:26:42 +01:00
select : {
id : true ,
name : true ,
email : true ,
expertiseTags : true ,
maxAssignments : true ,
_count : {
select : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
assignments : { where : { roundId } } ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectRoundStates = await prisma . projectRoundState . findMany ( {
where : { roundId } ,
2026-02-14 15:26:42 +01:00
select : { projectId : true } ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectIds = projectRoundStates . map ( ( prs ) = > prs . projectId )
2026-02-14 15:26:42 +01:00
const projects = await prisma . project . findMany ( {
where : { id : { in : projectIds } } ,
select : {
id : true ,
title : true ,
description : true ,
tags : true ,
teamName : true ,
2026-02-17 09:29:46 +01:00
projectTags : {
select : { tag : { select : { name : true } } , confidence : true } ,
} ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
_count : { select : { assignments : { where : { roundId } } } } ,
2026-02-14 15:26:42 +01:00
} ,
} )
2026-02-17 09:29:46 +01:00
// Enrich projects with tag confidence data for AI matching
const projectsWithConfidence = projects . map ( ( p ) = > ( {
. . . p ,
tagConfidences : p.projectTags.map ( ( pt ) = > ( {
name : pt.tag.name ,
confidence : pt.confidence ,
} ) ) ,
} ) )
2026-02-14 15:26:42 +01:00
const existingAssignments = await prisma . assignment . findMany ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId } ,
2026-02-14 15:26:42 +01:00
select : { userId : true , projectId : true } ,
} )
2026-02-19 12:59:35 +01:00
// Query COI records for this round to exclude conflicted juror-project pairs
const coiRecords = await prisma . conflictOfInterest . findMany ( {
where : {
roundId ,
hasConflict : true ,
} ,
select : { userId : true , projectId : true } ,
} )
const coiExclusions = new Set (
coiRecords . map ( ( c ) = > ` ${ c . userId } : ${ c . projectId } ` )
)
2026-02-14 15:26:42 +01:00
// Calculate batch info
const BATCH_SIZE = 15
const totalBatches = Math . ceil ( projects . length / BATCH_SIZE )
await prisma . assignmentJob . update ( {
where : { id : jobId } ,
data : { totalProjects : projects.length , totalBatches } ,
} )
// Progress callback
const onProgress : AssignmentProgressCallback = async ( progress ) = > {
await prisma . assignmentJob . update ( {
where : { id : jobId } ,
data : {
currentBatch : progress.currentBatch ,
processedCount : progress.processedCount ,
} ,
} )
}
// Build per-juror limits map for jurors with personal maxAssignments
const jurorLimits : Record < string , number > = { }
for ( const juror of jurors ) {
if ( juror . maxAssignments !== null && juror . maxAssignments !== undefined ) {
jurorLimits [ juror . id ] = juror . maxAssignments
}
}
const constraints = {
requiredReviewsPerProject : requiredReviews ,
minAssignmentsPerJuror ,
maxAssignmentsPerJuror ,
jurorLimits : Object.keys ( jurorLimits ) . length > 0 ? jurorLimits : undefined ,
existingAssignments : existingAssignments.map ( ( a ) = > ( {
jurorId : a.userId ,
projectId : a.projectId ,
} ) ) ,
}
const result = await generateAIAssignments (
jurors ,
2026-02-17 09:29:46 +01:00
projectsWithConfidence ,
2026-02-14 15:26:42 +01:00
constraints ,
userId ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId ,
2026-02-14 15:26:42 +01:00
onProgress
)
2026-02-19 12:59:35 +01:00
// Filter out suggestions that conflict with COI declarations
const filteredSuggestions = coiExclusions . size > 0
? result . suggestions . filter ( ( s ) = > ! coiExclusions . has ( ` ${ s . jurorId } : ${ s . projectId } ` ) )
: result . suggestions
2026-02-14 15:26:42 +01:00
// Enrich suggestions with names for storage
2026-02-19 12:59:35 +01:00
const enrichedSuggestions = filteredSuggestions . map ( ( s ) = > {
2026-02-14 15:26:42 +01:00
const juror = jurors . find ( ( j ) = > j . id === s . jurorId )
const project = projects . find ( ( p ) = > p . id === s . projectId )
return {
. . . s ,
jurorName : juror?.name || juror ? . email || 'Unknown' ,
projectTitle : project?.title || 'Unknown' ,
}
} )
// Mark job as completed and store suggestions
await prisma . assignmentJob . update ( {
where : { id : jobId } ,
data : {
status : 'COMPLETED' ,
completedAt : new Date ( ) ,
processedCount : projects.length ,
2026-02-19 12:59:35 +01:00
suggestionsCount : filteredSuggestions.length ,
2026-02-14 15:26:42 +01:00
suggestionsJson : enrichedSuggestions ,
fallbackUsed : result.fallbackUsed ? ? false ,
} ,
} )
await notifyAdmins ( {
type : NotificationTypes . AI_SUGGESTIONS_READY ,
title : 'AI Assignment Suggestions Ready' ,
2026-02-19 12:59:35 +01:00
message : ` AI generated ${ filteredSuggestions . length } assignment suggestions for ${ round . name || 'round' } ${ result . fallbackUsed ? ' (using fallback algorithm)' : '' } . ` ,
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
linkUrl : ` /admin/rounds/ ${ roundId } ` ,
2026-02-14 15:26:42 +01:00
linkLabel : 'View Suggestions' ,
priority : 'high' ,
metadata : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId ,
2026-02-14 15:26:42 +01:00
jobId ,
projectCount : projects.length ,
2026-02-19 12:59:35 +01:00
suggestionsCount : filteredSuggestions.length ,
2026-02-14 15:26:42 +01:00
fallbackUsed : result.fallbackUsed ,
} ,
} )
} catch ( error ) {
console . error ( '[AI Assignment Job] Error:' , error )
// Mark job as failed
await prisma . assignmentJob . update ( {
where : { id : jobId } ,
data : {
status : 'FAILED' ,
errorMessage : error instanceof Error ? error . message : 'Unknown error' ,
completedAt : new Date ( ) ,
} ,
} )
}
}
export const assignmentRouter = router ( {
listByStage : adminProcedure
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
. input ( z . object ( { roundId : z.string ( ) } ) )
2026-02-14 15:26:42 +01:00
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . assignment . findMany ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
include : {
user : { select : { id : true , name : true , email : true , expertiseTags : true } } ,
project : { select : { id : true , title : true , tags : true } } ,
evaluation : { select : { status : true , submittedAt : true } } ,
2026-02-19 18:30:01 +01:00
conflictOfInterest : { select : { hasConflict : true , conflictType : true , reviewAction : true } } ,
2026-02-14 15:26:42 +01:00
} ,
orderBy : { createdAt : 'desc' } ,
} )
} ) ,
/ * *
* List assignments for a project ( admin only )
* /
listByProject : adminProcedure
. input ( z . object ( { projectId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const assignments = await ctx . prisma . assignment . findMany ( {
where : { projectId : input.projectId } ,
include : {
user : { select : { id : true , name : true , email : true , expertiseTags : true , profileImageKey : true , profileImageProvider : true } } ,
evaluation : { select : { status : true , submittedAt : true , globalScore : true , binaryDecision : true } } ,
} ,
orderBy : { createdAt : 'desc' } ,
} )
// Attach avatar URLs
return Promise . all (
assignments . map ( async ( a ) = > ( {
. . . a ,
user : {
. . . a . user ,
avatarUrl : await getUserAvatarUrl ( a . user . profileImageKey , a . user . profileImageProvider ) ,
} ,
} ) )
)
} ) ,
/ * *
* Get my assignments ( for jury members )
* /
myAssignments : protectedProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) . optional ( ) ,
2026-02-14 15:26:42 +01:00
status : z.enum ( [ 'all' , 'pending' , 'completed' ] ) . default ( 'all' ) ,
} )
)
. query ( async ( { ctx , input } ) = > {
const where : Record < string , unknown > = {
userId : ctx.user.id ,
}
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
if ( input . roundId ) {
where . roundId = input . roundId
2026-02-14 15:26:42 +01:00
}
if ( input . status === 'pending' ) {
where . isCompleted = false
} else if ( input . status === 'completed' ) {
where . isCompleted = true
}
return ctx . prisma . assignment . findMany ( {
where ,
include : {
project : {
include : { files : true } ,
} ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
round : true ,
2026-02-14 15:26:42 +01:00
evaluation : true ,
} ,
orderBy : [ { isCompleted : 'asc' } , { createdAt : 'asc' } ] ,
} )
} ) ,
/ * *
* Get assignment by ID
* /
get : protectedProcedure
. input ( z . object ( { id : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const assignment = await ctx . prisma . assignment . findUniqueOrThrow ( {
where : { id : input.id } ,
include : {
user : { select : { id : true , name : true , email : true } } ,
project : { include : { files : true } } ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
round : { include : { evaluationForms : { where : { isActive : true } } } } ,
2026-02-14 15:26:42 +01:00
evaluation : true ,
} ,
} )
// Verify access
if (
ctx . user . role === 'JURY_MEMBER' &&
assignment . userId !== ctx . user . id
) {
throw new TRPCError ( {
code : 'FORBIDDEN' ,
message : 'You do not have access to this assignment' ,
} )
}
return assignment
} ) ,
/ * *
* Create a single assignment ( admin only )
* /
create : adminProcedure
. input (
z . object ( {
userId : z.string ( ) ,
projectId : z.string ( ) ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
isRequired : z.boolean ( ) . default ( true ) ,
forceOverride : z.boolean ( ) . default ( false ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
const existing = await ctx . prisma . assignment . findUnique ( {
where : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
userId_projectId_roundId : {
2026-02-14 15:26:42 +01:00
userId : input.userId ,
projectId : input.projectId ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} )
if ( existing ) {
throw new TRPCError ( {
code : 'CONFLICT' ,
message : 'This assignment already exists' ,
} )
}
const [ stage , user ] = await Promise . all ( [
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { configJson : true } ,
} ) ,
ctx . prisma . user . findUniqueOrThrow ( {
where : { id : input.userId } ,
select : { maxAssignments : true , name : true } ,
} ) ,
] )
const config = ( stage . configJson ? ? { } ) as Record < string , unknown >
2026-02-14 20:10:24 +01:00
const maxAssignmentsPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
2026-02-14 15:26:42 +01:00
const effectiveMax = user . maxAssignments ? ? maxAssignmentsPerJuror
const currentCount = await ctx . prisma . assignment . count ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { userId : input.userId , roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
} )
// Check if at or over limit
if ( currentCount >= effectiveMax ) {
if ( ! input . forceOverride ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` ${ user . name || 'Judge' } has reached their maximum limit of ${ effectiveMax } projects. Use manual override to proceed. ` ,
} )
}
// Log the override in audit
console . log ( ` [Assignment] Manual override: Assigning ${ user . name } beyond limit ( ${ currentCount } / ${ effectiveMax } ) ` )
}
const { forceOverride : _override , . . . assignmentData } = input
const assignment = await ctx . prisma . assignment . create ( {
data : {
. . . assignmentData ,
method : 'MANUAL' ,
createdBy : ctx.user.id ,
} ,
} )
// Audit log
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'CREATE' ,
entityType : 'Assignment' ,
entityId : assignment.id ,
detailsJson : input ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
const [ project , stageInfo ] = await Promise . all ( [
ctx . prisma . project . findUnique ( {
where : { id : input.projectId } ,
select : { title : true } ,
} ) ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
ctx . prisma . round . findUnique ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { name : true , windowCloseAt : true } ,
} ) ,
] )
if ( project && stageInfo ) {
const deadline = stageInfo . windowCloseAt
? new Date ( stageInfo . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
await createNotification ( {
userId : input.userId ,
type : NotificationTypes . ASSIGNED_TO_PROJECT ,
title : 'New Project Assignment' ,
message : ` You have been assigned to evaluate " ${ project . title } " for ${ stageInfo . name } . ` ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
linkUrl : ` /jury/competitions ` ,
2026-02-14 15:26:42 +01:00
linkLabel : 'View Assignment' ,
metadata : {
projectName : project.title ,
2026-02-19 12:59:35 +01:00
roundName : stageInfo.name ,
2026-02-14 15:26:42 +01:00
deadline ,
assignmentId : assignment.id ,
} ,
} )
}
return assignment
} ) ,
/ * *
* Bulk create assignments ( admin only )
* /
bulkCreate : adminProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
assignments : z.array (
z . object ( {
userId : z.string ( ) ,
projectId : z.string ( ) ,
} )
) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
// Fetch per-juror maxAssignments and current counts for capacity checking
const uniqueUserIds = [ . . . new Set ( input . assignments . map ( ( a ) = > a . userId ) ) ]
const users = await ctx . prisma . user . findMany ( {
where : { id : { in : uniqueUserIds } } ,
select : {
id : true ,
name : true ,
maxAssignments : true ,
_count : {
select : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
assignments : { where : { roundId : input.roundId } } ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} ,
} )
const userMap = new Map ( users . map ( ( u ) = > [ u . id , u ] ) )
// Get stage default max
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stage = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { configJson : true , name : true , windowCloseAt : true } ,
} )
const config = ( stage . configJson ? ? { } ) as Record < string , unknown >
2026-02-14 20:10:24 +01:00
const stageMaxPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
2026-02-14 15:26:42 +01:00
// Track running counts to handle multiple assignments to the same juror in one batch
const runningCounts = new Map < string , number > ( )
for ( const u of users ) {
runningCounts . set ( u . id , u . _count . assignments )
}
// Filter out assignments that would exceed a juror's limit
let skippedDueToCapacity = 0
const allowedAssignments = input . assignments . filter ( ( a ) = > {
const user = userMap . get ( a . userId )
if ( ! user ) return true // unknown user, let createMany handle it
const effectiveMax = user . maxAssignments ? ? stageMaxPerJuror
const currentCount = runningCounts . get ( a . userId ) ? ? 0
if ( currentCount >= effectiveMax ) {
skippedDueToCapacity ++
return false
}
// Increment running count for subsequent assignments to same user
runningCounts . set ( a . userId , currentCount + 1 )
return true
} )
const result = await ctx . prisma . assignment . createMany ( {
data : allowedAssignments.map ( ( a ) = > ( {
. . . a ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
method : 'BULK' ,
createdBy : ctx.user.id ,
} ) ) ,
skipDuplicates : true ,
} )
// Audit log
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'BULK_CREATE' ,
entityType : 'Assignment' ,
detailsJson : {
count : result.count ,
requested : input.assignments.length ,
skippedDueToCapacity ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
// Send notifications to assigned jury members (grouped by user)
if ( result . count > 0 && allowedAssignments . length > 0 ) {
// Group assignments by user to get counts
const userAssignmentCounts = allowedAssignments . reduce (
( acc , a ) = > {
acc [ a . userId ] = ( acc [ a . userId ] || 0 ) + 1
return acc
} ,
{ } as Record < string , number >
)
const deadline = stage ? . windowCloseAt
? new Date ( stage . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
const usersByProjectCount = new Map < number , string [ ] > ( )
for ( const [ userId , projectCount ] of Object . entries ( userAssignmentCounts ) ) {
const existing = usersByProjectCount . get ( projectCount ) || [ ]
existing . push ( userId )
usersByProjectCount . set ( projectCount , existing )
}
for ( const [ projectCount , userIds ] of usersByProjectCount ) {
if ( userIds . length === 0 ) continue
await createBulkNotifications ( {
userIds ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : ` ${ projectCount } Projects Assigned ` ,
message : ` You have been assigned ${ projectCount } project ${ projectCount > 1 ? 's' : '' } to evaluate for ${ stage ? . name || 'this stage' } . ` ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
linkUrl : ` /jury/competitions ` ,
2026-02-14 15:26:42 +01:00
linkLabel : 'View Assignments' ,
metadata : {
projectCount ,
2026-02-19 12:59:35 +01:00
roundName : stage?.name ,
2026-02-14 15:26:42 +01:00
deadline ,
} ,
} )
}
}
return {
created : result.count ,
requested : input.assignments.length ,
skipped : input.assignments.length - result . count ,
skippedDueToCapacity ,
}
} ) ,
/ * *
* Delete an assignment ( admin only )
* /
delete : adminProcedure
. input ( z . object ( { id : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const assignment = await ctx . prisma . assignment . delete ( {
where : { id : input.id } ,
} )
// Audit log
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'DELETE' ,
entityType : 'Assignment' ,
entityId : input.id ,
detailsJson : {
userId : assignment.userId ,
projectId : assignment.projectId ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
return assignment
} ) ,
/ * *
* Get assignment statistics for a round
* /
getStats : adminProcedure
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
. input ( z . object ( { roundId : z.string ( ) } ) )
2026-02-14 15:26:42 +01:00
. query ( async ( { ctx , input } ) = > {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stage = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { configJson : true } ,
} )
const config = ( stage . configJson ? ? { } ) as Record < string , unknown >
2026-02-19 12:59:35 +01:00
const requiredReviews = ( config . requiredReviewsPerProject as number ) ? ? 3
2026-02-14 15:26:42 +01:00
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectRoundStates = await ctx . prisma . projectRoundState . findMany ( {
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { projectId : true } ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectIds = projectRoundStates . map ( ( pss ) = > pss . projectId )
2026-02-14 15:26:42 +01:00
const [
totalAssignments ,
completedAssignments ,
assignmentsByUser ,
projectCoverage ,
] = await Promise . all ( [
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
ctx . prisma . assignment . count ( { where : { roundId : input.roundId } } ) ,
2026-02-14 15:26:42 +01:00
ctx . prisma . assignment . count ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId , isCompleted : true } ,
2026-02-14 15:26:42 +01:00
} ) ,
ctx . prisma . assignment . groupBy ( {
by : [ 'userId' ] ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
_count : true ,
} ) ,
ctx . prisma . project . findMany ( {
where : { id : { in : projectIds } } ,
select : {
id : true ,
title : true ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
_count : { select : { assignments : { where : { roundId : input.roundId } } } } ,
2026-02-14 15:26:42 +01:00
} ,
} ) ,
] )
const projectsWithFullCoverage = projectCoverage . filter (
( p ) = > p . _count . assignments >= requiredReviews
) . length
return {
totalAssignments ,
completedAssignments ,
completionPercentage :
totalAssignments > 0
? Math . round ( ( completedAssignments / totalAssignments ) * 100 )
: 0 ,
juryMembersAssigned : assignmentsByUser.length ,
projectsWithFullCoverage ,
totalProjects : projectCoverage.length ,
coveragePercentage :
projectCoverage . length > 0
? Math . round (
( projectsWithFullCoverage / projectCoverage . length ) * 100
)
: 0 ,
}
} ) ,
/ * *
* Get smart assignment suggestions using algorithm
* /
getSuggestions : adminProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
} )
)
. query ( async ( { ctx , input } ) = > {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stage = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-20 14:18:49 +01:00
select : { configJson : true , juryGroupId : true } ,
2026-02-14 15:26:42 +01:00
} )
const config = ( stage . configJson ? ? { } ) as Record < string , unknown >
2026-02-19 12:59:35 +01:00
const requiredReviews = ( config . requiredReviewsPerProject as number ) ? ? 3
2026-02-14 20:10:24 +01:00
const minAssignmentsPerJuror =
( config . minLoadPerJuror as number ) ? ?
( config . minAssignmentsPerJuror as number ) ? ?
1
const maxAssignmentsPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
// Extract category quotas if enabled
const categoryQuotasEnabled = config . categoryQuotasEnabled === true
const categoryQuotas = categoryQuotasEnabled
? ( config . categoryQuotas as Record < string , { min : number ; max : number } > | undefined )
: undefined
2026-02-14 15:26:42 +01:00
2026-02-20 14:18:49 +01:00
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds : string [ ] | undefined
if ( stage . juryGroupId ) {
const groupMembers = await ctx . prisma . juryGroupMember . findMany ( {
where : { juryGroupId : stage.juryGroupId } ,
select : { userId : true } ,
} )
scopedJurorIds = groupMembers . map ( ( m ) = > m . userId )
}
2026-02-14 15:26:42 +01:00
const jurors = await ctx . prisma . user . findMany ( {
2026-02-20 14:18:49 +01:00
where : {
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
. . . ( scopedJurorIds ? { id : { in : scopedJurorIds } } : { } ) ,
} ,
2026-02-14 15:26:42 +01:00
select : {
id : true ,
name : true ,
email : true ,
expertiseTags : true ,
maxAssignments : true ,
_count : {
select : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
assignments : { where : { roundId : input.roundId } } ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectRoundStates = await ctx . prisma . projectRoundState . findMany ( {
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { projectId : true } ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const projectIds = projectRoundStates . map ( ( pss ) = > pss . projectId )
2026-02-14 15:26:42 +01:00
const projects = await ctx . prisma . project . findMany ( {
where : { id : { in : projectIds } } ,
select : {
id : true ,
title : true ,
tags : true ,
2026-02-14 20:10:24 +01:00
competitionCategory : true ,
2026-02-14 15:26:42 +01:00
projectTags : {
include : { tag : { select : { name : true } } } ,
} ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
_count : { select : { assignments : { where : { roundId : input.roundId } } } } ,
2026-02-14 15:26:42 +01:00
} ,
} )
const existingAssignments = await ctx . prisma . assignment . findMany ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { userId : true , projectId : true } ,
} )
const assignmentSet = new Set (
existingAssignments . map ( ( a ) = > ` ${ a . userId } - ${ a . projectId } ` )
)
2026-02-14 20:10:24 +01:00
// Build per-juror category distribution for quota scoring
const jurorCategoryDistribution = new Map < string , Record < string , number > > ( )
if ( categoryQuotas ) {
const assignmentsWithCategory = await ctx . prisma . assignment . findMany ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 20:10:24 +01:00
select : {
userId : true ,
project : { select : { competitionCategory : true } } ,
} ,
} )
for ( const a of assignmentsWithCategory ) {
const cat = a . project . competitionCategory ? . toLowerCase ( ) . trim ( )
if ( ! cat ) continue
let catMap = jurorCategoryDistribution . get ( a . userId )
if ( ! catMap ) {
catMap = { }
jurorCategoryDistribution . set ( a . userId , catMap )
}
catMap [ cat ] = ( catMap [ cat ] || 0 ) + 1
}
}
2026-02-14 15:26:42 +01:00
const suggestions : Array < {
userId : string
jurorName : string
projectId : string
projectTitle : string
score : number
reasoning : string [ ]
} > = [ ]
for ( const project of projects ) {
if ( project . _count . assignments >= requiredReviews ) continue
const neededAssignments = requiredReviews - project . _count . assignments
const jurorScores = jurors
. filter ( ( j ) = > {
if ( assignmentSet . has ( ` ${ j . id } - ${ project . id } ` ) ) return false
const effectiveMax = j . maxAssignments ? ? maxAssignmentsPerJuror
if ( j . _count . assignments >= effectiveMax ) return false
return true
} )
. map ( ( juror ) = > {
const reasoning : string [ ] = [ ]
let score = 0
const projectTagNames = project . projectTags . map ( ( pt ) = > pt . tag . name . toLowerCase ( ) )
const matchingTags = projectTagNames . length > 0
? juror . expertiseTags . filter ( ( tag ) = >
projectTagNames . includes ( tag . toLowerCase ( ) )
)
: juror . expertiseTags . filter ( ( tag ) = >
project . tags . map ( ( t ) = > t . toLowerCase ( ) ) . includes ( tag . toLowerCase ( ) )
)
const totalTags = projectTagNames . length > 0 ? projectTagNames.length : project.tags.length
const expertiseScore =
matchingTags . length > 0
? matchingTags . length / Math . max ( totalTags , 1 )
: 0
score += expertiseScore * 35
if ( matchingTags . length > 0 ) {
reasoning . push ( ` Expertise match: ${ matchingTags . join ( ', ' ) } ` )
}
const effectiveMax = juror . maxAssignments ? ? maxAssignmentsPerJuror
const loadScore = 1 - juror . _count . assignments / effectiveMax
score += loadScore * 20
const underMinBonus =
juror . _count . assignments < minAssignmentsPerJuror
? ( minAssignmentsPerJuror - juror . _count . assignments ) * 3
: 0
score += Math . min ( 15 , underMinBonus )
if ( juror . _count . assignments < minAssignmentsPerJuror ) {
reasoning . push (
` Under target: ${ juror . _count . assignments } / ${ minAssignmentsPerJuror } min `
)
}
reasoning . push (
` Capacity: ${ juror . _count . assignments } / ${ effectiveMax } max `
)
2026-02-14 20:10:24 +01:00
// Category quota scoring
if ( categoryQuotas ) {
const jurorCategoryCounts = jurorCategoryDistribution . get ( juror . id ) || { }
const normalizedCat = project . competitionCategory ? . toLowerCase ( ) . trim ( )
if ( normalizedCat ) {
const quota = Object . entries ( categoryQuotas ) . find (
( [ key ] ) = > key . toLowerCase ( ) . trim ( ) === normalizedCat
)
if ( quota ) {
const [ , { min , max } ] = quota
const currentCount = jurorCategoryCounts [ normalizedCat ] || 0
if ( currentCount >= max ) {
score -= 25
reasoning . push ( ` Category quota exceeded (-25) ` )
} else if ( currentCount < min ) {
const otherAboveMin = Object . entries ( categoryQuotas ) . some ( ( [ key , q ] ) = > {
if ( key . toLowerCase ( ) . trim ( ) === normalizedCat ) return false
return ( jurorCategoryCounts [ key . toLowerCase ( ) . trim ( ) ] || 0 ) >= q . min
} )
if ( otherAboveMin ) {
score += 10
reasoning . push ( ` Category quota bonus (+10) ` )
}
}
}
}
}
2026-02-14 15:26:42 +01:00
return {
userId : juror.id ,
jurorName : juror.name || juror . email || 'Unknown' ,
projectId : project.id ,
projectTitle : project.title || 'Unknown' ,
score ,
reasoning ,
}
} )
. sort ( ( a , b ) = > b . score - a . score )
. slice ( 0 , neededAssignments )
suggestions . push ( . . . jurorScores )
}
return suggestions . sort ( ( a , b ) = > b . score - a . score )
} ) ,
/ * *
* Check if AI assignment is available
* /
isAIAvailable : adminProcedure.query ( async ( ) = > {
return isOpenAIConfigured ( )
} ) ,
/ * *
* Get AI - powered assignment suggestions ( retrieves from completed job )
* /
getAISuggestions : adminProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
useAI : z.boolean ( ) . default ( true ) ,
} )
)
. query ( async ( { ctx , input } ) = > {
const completedJob = await ctx . prisma . assignmentJob . findFirst ( {
where : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
status : 'COMPLETED' ,
} ,
orderBy : { completedAt : 'desc' } ,
select : {
suggestionsJson : true ,
fallbackUsed : true ,
completedAt : true ,
} ,
} )
if ( completedJob ? . suggestionsJson ) {
const suggestions = completedJob . suggestionsJson as Array < {
jurorId : string
jurorName : string
projectId : string
projectTitle : string
confidenceScore : number
expertiseMatchScore : number
reasoning : string
} >
const existingAssignments = await ctx . prisma . assignment . findMany ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { userId : true , projectId : true } ,
} )
const assignmentSet = new Set (
existingAssignments . map ( ( a ) = > ` ${ a . userId } - ${ a . projectId } ` )
)
const filteredSuggestions = suggestions . filter (
( s ) = > ! assignmentSet . has ( ` ${ s . jurorId } - ${ s . projectId } ` )
)
return {
success : true ,
suggestions : filteredSuggestions ,
fallbackUsed : completedJob.fallbackUsed ,
error : null ,
generatedAt : completedJob.completedAt ,
}
}
return {
success : true ,
suggestions : [ ] ,
fallbackUsed : false ,
error : null ,
generatedAt : null ,
}
} ) ,
/ * *
* Apply AI - suggested assignments
* /
applyAISuggestions : adminProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
assignments : z.array (
z . object ( {
userId : z.string ( ) ,
projectId : z.string ( ) ,
confidenceScore : z.number ( ) . optional ( ) ,
expertiseMatchScore : z.number ( ) . optional ( ) ,
reasoning : z.string ( ) . optional ( ) ,
} )
) ,
usedAI : z.boolean ( ) . default ( false ) ,
forceOverride : z.boolean ( ) . default ( false ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
let assignmentsToCreate = input . assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if ( ! input . forceOverride ) {
const uniqueUserIds = [ . . . new Set ( input . assignments . map ( ( a ) = > a . userId ) ) ]
const users = await ctx . prisma . user . findMany ( {
where : { id : { in : uniqueUserIds } } ,
select : {
id : true ,
maxAssignments : true ,
_count : {
select : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
assignments : { where : { roundId : input.roundId } } ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} ,
} )
const userMap = new Map ( users . map ( ( u ) = > [ u . id , u ] ) )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stageData = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { configJson : true } ,
} )
const config = ( stageData . configJson ? ? { } ) as Record < string , unknown >
2026-02-14 20:10:24 +01:00
const stageMaxPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
2026-02-14 15:26:42 +01:00
const runningCounts = new Map < string , number > ( )
for ( const u of users ) {
runningCounts . set ( u . id , u . _count . assignments )
}
assignmentsToCreate = input . assignments . filter ( ( a ) = > {
const user = userMap . get ( a . userId )
if ( ! user ) return true
const effectiveMax = user . maxAssignments ? ? stageMaxPerJuror
const currentCount = runningCounts . get ( a . userId ) ? ? 0
if ( currentCount >= effectiveMax ) {
skippedDueToCapacity ++
return false
}
runningCounts . set ( a . userId , currentCount + 1 )
return true
} )
}
const created = await ctx . prisma . assignment . createMany ( {
data : assignmentsToCreate.map ( ( a ) = > ( {
userId : a.userId ,
projectId : a.projectId ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
method : input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM' ,
aiConfidenceScore : a.confidenceScore ,
expertiseMatchScore : a.expertiseMatchScore ,
aiReasoning : a.reasoning ,
createdBy : ctx.user.id ,
} ) ) ,
skipDuplicates : true ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS' ,
entityType : 'Assignment' ,
detailsJson : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
count : created.count ,
usedAI : input.usedAI ,
forceOverride : input.forceOverride ,
skippedDueToCapacity ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
if ( created . count > 0 ) {
const userAssignmentCounts = assignmentsToCreate . reduce (
( acc , a ) = > {
acc [ a . userId ] = ( acc [ a . userId ] || 0 ) + 1
return acc
} ,
{ } as Record < string , number >
)
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stage = await ctx . prisma . round . findUnique ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { name : true , windowCloseAt : true } ,
} )
const deadline = stage ? . windowCloseAt
? new Date ( stage . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
const usersByProjectCount = new Map < number , string [ ] > ( )
for ( const [ userId , projectCount ] of Object . entries ( userAssignmentCounts ) ) {
const existing = usersByProjectCount . get ( projectCount ) || [ ]
existing . push ( userId )
usersByProjectCount . set ( projectCount , existing )
}
for ( const [ projectCount , userIds ] of usersByProjectCount ) {
if ( userIds . length === 0 ) continue
await createBulkNotifications ( {
userIds ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : ` ${ projectCount } Projects Assigned ` ,
message : ` You have been assigned ${ projectCount } project ${ projectCount > 1 ? 's' : '' } to evaluate for ${ stage ? . name || 'this stage' } . ` ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
linkUrl : ` /jury/competitions ` ,
2026-02-14 15:26:42 +01:00
linkLabel : 'View Assignments' ,
metadata : {
projectCount ,
2026-02-19 12:59:35 +01:00
roundName : stage?.name ,
2026-02-14 15:26:42 +01:00
deadline ,
} ,
} )
}
}
return {
created : created.count ,
requested : input.assignments.length ,
skippedDueToCapacity ,
}
} ) ,
/ * *
* Apply suggested assignments
* /
applySuggestions : adminProcedure
. input (
z . object ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : z.string ( ) ,
2026-02-14 15:26:42 +01:00
assignments : z.array (
z . object ( {
userId : z.string ( ) ,
projectId : z.string ( ) ,
reasoning : z.string ( ) . optional ( ) ,
} )
) ,
forceOverride : z.boolean ( ) . default ( false ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
let assignmentsToCreate = input . assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if ( ! input . forceOverride ) {
const uniqueUserIds = [ . . . new Set ( input . assignments . map ( ( a ) = > a . userId ) ) ]
const users = await ctx . prisma . user . findMany ( {
where : { id : { in : uniqueUserIds } } ,
select : {
id : true ,
maxAssignments : true ,
_count : {
select : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
assignments : { where : { roundId : input.roundId } } ,
2026-02-14 15:26:42 +01:00
} ,
} ,
} ,
} )
const userMap = new Map ( users . map ( ( u ) = > [ u . id , u ] ) )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stageData = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { configJson : true } ,
} )
const config = ( stageData . configJson ? ? { } ) as Record < string , unknown >
2026-02-14 20:10:24 +01:00
const stageMaxPerJuror =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
2026-02-14 15:26:42 +01:00
const runningCounts = new Map < string , number > ( )
for ( const u of users ) {
runningCounts . set ( u . id , u . _count . assignments )
}
assignmentsToCreate = input . assignments . filter ( ( a ) = > {
const user = userMap . get ( a . userId )
if ( ! user ) return true
const effectiveMax = user . maxAssignments ? ? stageMaxPerJuror
const currentCount = runningCounts . get ( a . userId ) ? ? 0
if ( currentCount >= effectiveMax ) {
skippedDueToCapacity ++
return false
}
runningCounts . set ( a . userId , currentCount + 1 )
return true
} )
}
const created = await ctx . prisma . assignment . createMany ( {
data : assignmentsToCreate.map ( ( a ) = > ( {
userId : a.userId ,
projectId : a.projectId ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
method : 'ALGORITHM' ,
aiReasoning : a.reasoning ,
createdBy : ctx.user.id ,
} ) ) ,
skipDuplicates : true ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'APPLY_SUGGESTIONS' ,
entityType : 'Assignment' ,
detailsJson : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
count : created.count ,
forceOverride : input.forceOverride ,
skippedDueToCapacity ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
if ( created . count > 0 ) {
const userAssignmentCounts = assignmentsToCreate . reduce (
( acc , a ) = > {
acc [ a . userId ] = ( acc [ a . userId ] || 0 ) + 1
return acc
} ,
{ } as Record < string , number >
)
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
const stage = await ctx . prisma . round . findUnique ( {
where : { id : input.roundId } ,
2026-02-14 15:26:42 +01:00
select : { name : true , windowCloseAt : true } ,
} )
const deadline = stage ? . windowCloseAt
? new Date ( stage . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
const usersByProjectCount = new Map < number , string [ ] > ( )
for ( const [ userId , projectCount ] of Object . entries ( userAssignmentCounts ) ) {
const existing = usersByProjectCount . get ( projectCount ) || [ ]
existing . push ( userId )
usersByProjectCount . set ( projectCount , existing )
}
for ( const [ projectCount , userIds ] of usersByProjectCount ) {
if ( userIds . length === 0 ) continue
await createBulkNotifications ( {
userIds ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : ` ${ projectCount } Projects Assigned ` ,
message : ` You have been assigned ${ projectCount } project ${ projectCount > 1 ? 's' : '' } to evaluate for ${ stage ? . name || 'this stage' } . ` ,
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
linkUrl : ` /jury/competitions ` ,
2026-02-14 15:26:42 +01:00
linkLabel : 'View Assignments' ,
metadata : {
projectCount ,
2026-02-19 12:59:35 +01:00
roundName : stage?.name ,
2026-02-14 15:26:42 +01:00
deadline ,
} ,
} )
}
}
return {
created : created.count ,
requested : input.assignments.length ,
skippedDueToCapacity ,
}
} ) ,
/ * *
* Start an AI assignment job ( background processing )
* /
startAIAssignmentJob : adminProcedure
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
. input ( z . object ( { roundId : z.string ( ) } ) )
2026-02-14 15:26:42 +01:00
. mutation ( async ( { ctx , input } ) = > {
const existingJob = await ctx . prisma . assignmentJob . findFirst ( {
where : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
status : { in : [ 'PENDING' , 'RUNNING' ] } ,
} ,
} )
if ( existingJob ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'An AI assignment job is already running for this stage' ,
} )
}
if ( ! isOpenAIConfigured ( ) ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'OpenAI API is not configured' ,
} )
}
const job = await ctx . prisma . assignmentJob . create ( {
data : {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
roundId : input.roundId ,
2026-02-14 15:26:42 +01:00
status : 'PENDING' ,
} ,
} )
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
runAIAssignmentJob ( job . id , input . roundId , ctx . user . id ) . catch ( console . error )
2026-02-14 15:26:42 +01:00
return { jobId : job.id }
} ) ,
/ * *
* Get AI assignment job status ( for polling )
* /
getAIAssignmentJobStatus : adminProcedure
. input ( z . object ( { jobId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const job = await ctx . prisma . assignmentJob . findUniqueOrThrow ( {
where : { id : input.jobId } ,
} )
return {
id : job.id ,
status : job.status ,
totalProjects : job.totalProjects ,
totalBatches : job.totalBatches ,
currentBatch : job.currentBatch ,
processedCount : job.processedCount ,
suggestionsCount : job.suggestionsCount ,
fallbackUsed : job.fallbackUsed ,
errorMessage : job.errorMessage ,
startedAt : job.startedAt ,
completedAt : job.completedAt ,
}
} ) ,
/ * *
* Get the latest AI assignment job for a round
* /
getLatestAIAssignmentJob : adminProcedure
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
. input ( z . object ( { roundId : z.string ( ) } ) )
2026-02-14 15:26:42 +01:00
. query ( async ( { ctx , input } ) = > {
const job = await ctx . prisma . assignmentJob . findFirst ( {
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
where : { roundId : input.roundId } ,
2026-02-14 15:26:42 +01:00
orderBy : { createdAt : 'desc' } ,
} )
if ( ! job ) return null
return {
id : job.id ,
status : job.status ,
totalProjects : job.totalProjects ,
totalBatches : job.totalBatches ,
currentBatch : job.currentBatch ,
processedCount : job.processedCount ,
suggestionsCount : job.suggestionsCount ,
fallbackUsed : job.fallbackUsed ,
errorMessage : job.errorMessage ,
startedAt : job.startedAt ,
completedAt : job.completedAt ,
createdAt : job.createdAt ,
}
} ) ,
2026-02-19 12:15:51 +01:00
/ * *
* Notify all jurors of their current assignments for a round ( admin only ) .
2026-02-19 12:59:35 +01:00
* Sends in - app notifications ( emails are handled by maybeSendEmail via createBulkNotifications ) .
2026-02-19 12:15:51 +01:00
* /
notifyJurorsOfAssignments : adminProcedure
. input ( z . object ( { roundId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { name : true , windowCloseAt : true } ,
} )
// Get all assignments grouped by user
const assignments = await ctx . prisma . assignment . findMany ( {
where : { roundId : input.roundId } ,
select : { userId : true } ,
} )
if ( assignments . length === 0 ) {
2026-02-19 12:59:35 +01:00
return { sent : 0 , jurorCount : 0 }
2026-02-19 12:15:51 +01:00
}
// Count assignments per user
const userCounts : Record < string , number > = { }
for ( const a of assignments ) {
userCounts [ a . userId ] = ( userCounts [ a . userId ] || 0 ) + 1
}
const deadline = round . windowCloseAt
? new Date ( round . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
// Create in-app notifications grouped by project count
const usersByProjectCount = new Map < number , string [ ] > ( )
for ( const [ userId , projectCount ] of Object . entries ( userCounts ) ) {
const existing = usersByProjectCount . get ( projectCount ) || [ ]
existing . push ( userId )
usersByProjectCount . set ( projectCount , existing )
}
let totalSent = 0
for ( const [ projectCount , userIds ] of usersByProjectCount ) {
if ( userIds . length === 0 ) continue
await createBulkNotifications ( {
userIds ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : ` ${ projectCount } Projects Assigned ` ,
message : ` You have been assigned ${ projectCount } project ${ projectCount > 1 ? 's' : '' } to evaluate for ${ round . name || 'this round' } . ` ,
linkUrl : ` /jury/competitions ` ,
linkLabel : 'View Assignments' ,
2026-02-19 12:59:35 +01:00
metadata : { projectCount , roundName : round.name , deadline } ,
2026-02-19 12:15:51 +01:00
} )
totalSent += userIds . length
}
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'NOTIFY_JURORS_OF_ASSIGNMENTS' ,
entityType : 'Round' ,
entityId : input.roundId ,
detailsJson : {
jurorCount : Object.keys ( userCounts ) . length ,
totalAssignments : assignments.length ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
2026-02-19 12:59:35 +01:00
return { sent : totalSent , jurorCount : Object.keys ( userCounts ) . length }
2026-02-19 12:15:51 +01:00
} ) ,
2026-02-19 17:18:07 +01:00
notifySingleJurorOfAssignments : adminProcedure
. input ( z . object ( { roundId : z.string ( ) , userId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { name : true , windowCloseAt : true } ,
} )
const assignments = await ctx . prisma . assignment . findMany ( {
where : { roundId : input.roundId , userId : input.userId } ,
select : { id : true } ,
} )
if ( assignments . length === 0 ) {
throw new TRPCError ( { code : 'NOT_FOUND' , message : 'No assignments found for this juror in this round' } )
}
const projectCount = assignments . length
const deadline = round . windowCloseAt
? new Date ( round . windowCloseAt ) . toLocaleDateString ( 'en-US' , {
weekday : 'long' ,
year : 'numeric' ,
month : 'long' ,
day : 'numeric' ,
} )
: undefined
await createBulkNotifications ( {
userIds : [ input . userId ] ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : ` ${ projectCount } Projects Assigned ` ,
message : ` You have been assigned ${ projectCount } project ${ projectCount > 1 ? 's' : '' } to evaluate for ${ round . name || 'this round' } . ` ,
linkUrl : ` /jury/competitions ` ,
linkLabel : 'View Assignments' ,
metadata : { projectCount , roundName : round.name , deadline } ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'NOTIFY_SINGLE_JUROR_OF_ASSIGNMENTS' ,
entityType : 'Round' ,
entityId : input.roundId ,
detailsJson : {
targetUserId : input.userId ,
assignmentCount : projectCount ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
return { sent : 1 , projectCount }
} ) ,
2026-02-19 18:30:01 +01:00
reassignCOI : adminProcedure
. input ( z . object ( { assignmentId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const result = await reassignAfterCOI ( {
assignmentId : input.assignmentId ,
auditUserId : ctx.user.id ,
auditIp : ctx.ip ,
auditUserAgent : ctx.userAgent ,
} )
if ( ! result ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'No eligible juror found for reassignment. All jurors are either already assigned to this project, have a COI, or are at their assignment limit.' ,
} )
}
return result
} ) ,
2026-02-19 23:12:55 +01:00
reassignDroppedJuror : adminProcedure
. input ( z . object ( { roundId : z.string ( ) , jurorId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
return reassignDroppedJurorAssignments ( {
roundId : input.roundId ,
droppedJurorId : input.jurorId ,
auditUserId : ctx.user.id ,
auditIp : ctx.ip ,
auditUserAgent : ctx.userAgent ,
} )
} ) ,
2026-02-20 14:18:49 +01:00
2026-02-21 18:50:29 +01:00
/ * *
* Get transfer candidates : which of the source juror ' s assignments can be moved ,
* and which other jurors are eligible to receive them .
* /
getTransferCandidates : adminProcedure
. input ( z . object ( {
roundId : z.string ( ) ,
sourceJurorId : z.string ( ) ,
assignmentIds : z.array ( z . string ( ) ) ,
} ) )
. query ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { id : true , name : true , configJson : true , juryGroupId : true } ,
} )
const config = ( round . configJson ? ? { } ) as Record < string , unknown >
const fallbackCap =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
// Fetch requested assignments — must belong to source juror
const requestedAssignments = await ctx . prisma . assignment . findMany ( {
where : {
id : { in : input . assignmentIds } ,
roundId : input.roundId ,
userId : input.sourceJurorId ,
} ,
select : {
id : true ,
projectId : true ,
project : { select : { title : true } } ,
evaluation : { select : { status : true } } ,
} ,
} )
// Filter to movable only
const assignments = requestedAssignments . map ( ( a ) = > ( {
id : a.id ,
projectId : a.projectId ,
projectTitle : a.project.title ,
evalStatus : a.evaluation?.status ? ? null ,
movable : ! a . evaluation || MOVABLE_EVAL_STATUSES . includes ( a . evaluation . status as typeof MOVABLE_EVAL_STATUSES [ number ] ) ,
} ) )
const movableProjectIds = assignments
. filter ( ( a ) = > a . movable )
. map ( ( a ) = > a . projectId )
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
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 . sourceJurorId )
. map ( ( m ) = > m . user )
} else {
const roundJurorIds = await ctx . prisma . assignment . findMany ( {
where : { roundId : input.roundId } ,
select : { userId : true } ,
distinct : [ 'userId' ] ,
} )
const activeRoundJurorIds = roundJurorIds
. map ( ( a ) = > a . userId )
. filter ( ( id ) = > id !== input . sourceJurorId )
candidateJurors = activeRoundJurorIds . length > 0
? await ctx . prisma . user . findMany ( {
where : {
id : { in : activeRoundJurorIds } ,
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
} ,
select : { id : true , name : true , email : true , maxAssignments : true } ,
} )
: [ ]
}
const candidateIds = candidateJurors . map ( ( j ) = > j . id )
// Existing assignments, loads, COI pairs
const existingAssignments = await ctx . prisma . assignment . findMany ( {
where : { roundId : input.roundId } ,
select : { userId : true , projectId : true } ,
} )
const currentLoads = new Map < string , number > ( )
for ( const a of existingAssignments ) {
currentLoads . set ( a . userId , ( currentLoads . get ( a . userId ) ? ? 0 ) + 1 )
}
const alreadyAssigned = new Set ( existingAssignments . map ( ( a ) = > ` ${ a . userId } : ${ a . projectId } ` ) )
// Completed evaluations count per candidate
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 ) {
const uid = e . assignment . userId
completedCounts . set ( uid , ( completedCounts . get ( uid ) ? ? 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 } ` ) )
// Build candidate list with eligibility per project
const candidates = candidateJurors . map ( ( j ) = > {
const load = currentLoads . get ( j . id ) ? ? 0
const cap = j . maxAssignments ? ? fallbackCap
const completed = completedCounts . get ( j . id ) ? ? 0
const allCompleted = load > 0 && completed === load
const eligibleProjectIds = movableProjectIds . filter ( ( pid ) = >
! alreadyAssigned . has ( ` ${ j . id } : ${ pid } ` ) &&
! coiPairs . has ( ` ${ j . id } : ${ pid } ` ) &&
load < cap
)
return {
userId : j.id ,
name : j.name || j . email ,
email : j.email ,
currentLoad : load ,
cap ,
allCompleted ,
eligibleProjectIds ,
}
} )
// Sort: not-all-done first, then by lowest load
candidates . sort ( ( a , b ) = > {
if ( a . allCompleted !== b . allCompleted ) return a . allCompleted ? 1 : - 1
return a . currentLoad - b . currentLoad
} )
return { assignments , candidates }
} ) ,
/ * *
* Transfer specific assignments from one juror to destination jurors .
* /
transferAssignments : adminProcedure
. input ( z . object ( {
roundId : z.string ( ) ,
sourceJurorId : z.string ( ) ,
transfers : z.array ( z . object ( {
assignmentId : z.string ( ) ,
destinationJurorId : z.string ( ) ,
} ) ) ,
forceOverCap : z.boolean ( ) . default ( false ) ,
} ) )
. mutation ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { id : true , name : true , configJson : true , juryGroupId : true } ,
} )
const config = ( round . configJson ? ? { } ) as Record < string , unknown >
const fallbackCap =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
// Verify all assignments belong to source juror and are movable
const assignmentIds = input . transfers . map ( ( t ) = > t . assignmentId )
const sourceAssignments = await ctx . prisma . assignment . findMany ( {
where : {
id : { in : assignmentIds } ,
roundId : input.roundId ,
userId : input.sourceJurorId ,
OR : [
{ evaluation : null } ,
{ evaluation : { status : { in : [ . . . MOVABLE_EVAL_STATUSES ] } } } ,
] ,
} ,
select : {
id : true ,
projectId : true ,
juryGroupId : true ,
isRequired : true ,
project : { select : { title : true } } ,
} ,
} )
const sourceMap = new Map ( sourceAssignments . map ( ( a ) = > [ a . id , a ] ) )
// Build candidate pool data
const destinationIds = [ . . . new Set ( input . transfers . map ( ( t ) = > t . destinationJurorId ) ) ]
const destinationUsers = await ctx . prisma . user . findMany ( {
where : { id : { in : destinationIds } } ,
select : { id : true , name : true , email : true , maxAssignments : true } ,
} )
const destUserMap = new Map ( destinationUsers . map ( ( u ) = > [ u . id , u ] ) )
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 : destinationIds } ,
} ,
select : { userId : true , projectId : true } ,
} )
const coiPairs = new Set ( coiRecords . map ( ( c ) = > ` ${ c . userId } : ${ c . projectId } ` ) )
// Validate each transfer
type PlannedMove = {
assignmentId : string
projectId : string
projectTitle : string
destinationJurorId : string
juryGroupId : string | null
isRequired : boolean
}
const plannedMoves : PlannedMove [ ] = [ ]
const failed : { assignmentId : string ; reason : string } [ ] = [ ]
for ( const transfer of input . transfers ) {
const assignment = sourceMap . get ( transfer . assignmentId )
if ( ! assignment ) {
failed . push ( { assignmentId : transfer.assignmentId , reason : 'Assignment not found or not movable' } )
continue
}
const destUser = destUserMap . get ( transfer . destinationJurorId )
if ( ! destUser ) {
failed . push ( { assignmentId : transfer.assignmentId , reason : 'Destination juror not found' } )
continue
}
if ( alreadyAssigned . has ( ` ${ transfer . destinationJurorId } : ${ assignment . projectId } ` ) ) {
failed . push ( { assignmentId : transfer.assignmentId , reason : ` ${ destUser . name || destUser . email } is already assigned to this project ` } )
continue
}
if ( coiPairs . has ( ` ${ transfer . destinationJurorId } : ${ assignment . projectId } ` ) ) {
failed . push ( { assignmentId : transfer.assignmentId , reason : ` ${ destUser . name || destUser . email } has a COI with this project ` } )
continue
}
const destCap = destUser . maxAssignments ? ? fallbackCap
const destLoad = currentLoads . get ( transfer . destinationJurorId ) ? ? 0
if ( destLoad >= destCap && ! input . forceOverCap ) {
failed . push ( { assignmentId : transfer.assignmentId , reason : ` ${ destUser . name || destUser . email } is at cap ( ${ destLoad } / ${ destCap } ) ` } )
continue
}
plannedMoves . push ( {
assignmentId : assignment.id ,
projectId : assignment.projectId ,
projectTitle : assignment.project.title ,
destinationJurorId : transfer.destinationJurorId ,
juryGroupId : assignment.juryGroupId ? ? round . juryGroupId ,
isRequired : assignment.isRequired ,
} )
// Track updated load for subsequent transfers to same destination
alreadyAssigned . add ( ` ${ transfer . destinationJurorId } : ${ assignment . projectId } ` )
currentLoads . set ( transfer . destinationJurorId , destLoad + 1 )
}
// Execute in transaction with TOCTOU guard
const actualMoves : ( PlannedMove & { newAssignmentId : string } ) [ ] = [ ]
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.sourceJurorId ,
OR : [
{ evaluation : null } ,
{ evaluation : { status : { in : [ . . . MOVABLE_EVAL_STATUSES ] } } } ,
] ,
} ,
} )
if ( deleted . count === 0 ) {
failed . push ( { assignmentId : move.assignmentId , reason : 'Assignment was modified concurrently' } )
continue
}
const created = await tx . assignment . create ( {
data : {
roundId : input.roundId ,
projectId : move.projectId ,
userId : move.destinationJurorId ,
juryGroupId : move.juryGroupId ? ? undefined ,
isRequired : move.isRequired ,
method : 'MANUAL' ,
createdBy : ctx.user.id ,
} ,
} )
actualMoves . push ( { . . . move , newAssignmentId : created.id } )
}
} )
}
// Notify destination jurors
if ( actualMoves . length > 0 ) {
const destCounts : Record < string , number > = { }
for ( const move of actualMoves ) {
destCounts [ move . destinationJurorId ] = ( destCounts [ move . destinationJurorId ] ? ? 0 ) + 1
}
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' } ,
} )
// Notify admins
const sourceJuror = await ctx . prisma . user . findUnique ( {
where : { id : input.sourceJurorId } ,
select : { name : true , email : true } ,
} )
const sourceName = sourceJuror ? . name || sourceJuror ? . email || 'Unknown'
const topReceivers = Object . entries ( destCounts )
. map ( ( [ jurorId , count ] ) = > {
const u = destUserMap . get ( jurorId )
return ` ${ u ? . name || u ? . email || jurorId } ( ${ count } ) `
} )
. join ( ', ' )
await notifyAdmins ( {
type : NotificationTypes . EVALUATION_MILESTONE ,
title : 'Assignment Transfer' ,
message : ` Transferred ${ actualMoves . length } project(s) from ${ sourceName } to: ${ topReceivers } . ${ failed . length > 0 ? ` ${ failed . length } transfer(s) failed. ` : '' } ` ,
linkUrl : ` /admin/rounds/ ${ round . id } ` ,
linkLabel : 'View Round' ,
metadata : {
roundId : round.id ,
sourceJurorId : input.sourceJurorId ,
movedCount : actualMoves.length ,
failedCount : failed.length ,
} ,
} )
// Audit
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'ASSIGNMENT_TRANSFER' ,
entityType : 'Round' ,
entityId : round.id ,
detailsJson : {
sourceJurorId : input.sourceJurorId ,
sourceJurorName : sourceName ,
movedCount : actualMoves.length ,
failedCount : failed.length ,
moves : actualMoves.map ( ( m ) = > ( {
projectId : m.projectId ,
projectTitle : m.projectTitle ,
newJurorId : m.destinationJurorId ,
newJurorName : destUserMap.get ( m . destinationJurorId ) ? . name || destUserMap . get ( m . destinationJurorId ) ? . email || m . destinationJurorId ,
} ) ) ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
}
return {
succeeded : actualMoves.map ( ( m ) = > ( {
assignmentId : m.assignmentId ,
projectId : m.projectId ,
destinationJurorId : m.destinationJurorId ,
} ) ) ,
failed ,
}
} ) ,
/ * *
* Preview the impact of lowering a juror ' s cap below their current load .
* /
getOverCapPreview : adminProcedure
. input ( z . object ( {
roundId : z.string ( ) ,
jurorId : z.string ( ) ,
newCap : z.number ( ) . int ( ) . min ( 1 ) ,
} ) )
. query ( async ( { ctx , input } ) = > {
const total = await ctx . prisma . assignment . count ( {
where : { roundId : input.roundId , userId : input.jurorId } ,
} )
const immovableCount = await ctx . prisma . assignment . count ( {
where : {
roundId : input.roundId ,
userId : input.jurorId ,
evaluation : { status : { notIn : [ . . . MOVABLE_EVAL_STATUSES ] } } ,
} ,
} )
const movableCount = total - immovableCount
const overCapCount = Math . max ( 0 , total - input . newCap )
return {
total ,
overCapCount ,
movableOverCap : Math.min ( overCapCount , movableCount ) ,
immovableOverCap : Math.max ( 0 , overCapCount - movableCount ) ,
}
} ) ,
/ * *
* Redistribute over - cap assignments after lowering a juror ' s cap .
* Moves the newest / least - progressed movable assignments to other eligible jurors .
* /
redistributeOverCap : adminProcedure
. input ( z . object ( {
roundId : z.string ( ) ,
jurorId : z.string ( ) ,
newCap : z.number ( ) . int ( ) . min ( 1 ) ,
} ) )
. mutation ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { id : true , name : true , configJson : true , juryGroupId : true } ,
} )
const config = ( round . configJson ? ? { } ) as Record < string , unknown >
const fallbackCap =
( config . maxLoadPerJuror as number ) ? ?
( config . maxAssignmentsPerJuror as number ) ? ?
20
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
const jurorAssignments = await ctx . prisma . assignment . findMany ( {
where : { roundId : input.roundId , userId : input.jurorId } ,
select : {
id : true ,
projectId : true ,
juryGroupId : true ,
isRequired : true ,
createdAt : true ,
project : { select : { title : true } } ,
evaluation : { select : { status : true } } ,
} ,
orderBy : { createdAt : 'desc' } ,
} )
const overCapCount = Math . max ( 0 , jurorAssignments . length - input . newCap )
if ( overCapCount === 0 ) {
return { redistributed : 0 , failed : 0 , failedProjects : [ ] as string [ ] , moves : [ ] as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] }
}
// Separate movable and immovable, pick the newest movable ones for redistribution
const movable = jurorAssignments . filter (
( a ) = > ! a . evaluation || MOVABLE_EVAL_STATUSES . includes ( a . evaluation . status as typeof MOVABLE_EVAL_STATUSES [ number ] )
)
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
movable . sort ( ( a , b ) = > {
const statusOrder = ( s : string | null ) = > s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
const diff = statusOrder ( a . evaluation ? . status ? ? null ) - statusOrder ( b . evaluation ? . status ? ? null )
if ( diff !== 0 ) return diff
return b . createdAt . getTime ( ) - a . createdAt . getTime ( )
} )
const assignmentsToMove = movable . slice ( 0 , overCapCount )
if ( assignmentsToMove . length === 0 ) {
return { redistributed : 0 , failed : 0 , failedProjects : [ ] as string [ ] , moves : [ ] as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] }
}
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
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 activeRoundJurorIds = roundJurorIds
. map ( ( a ) = > a . userId )
. filter ( ( id ) = > id !== input . jurorId )
candidateJurors = activeRoundJurorIds . length > 0
? await ctx . prisma . user . findMany ( {
where : {
id : { in : activeRoundJurorIds } ,
role : 'JURY_MEMBER' ,
status : 'ACTIVE' ,
} ,
select : { id : true , name : true , email : true , maxAssignments : true } ,
} )
: [ ]
}
if ( candidateJurors . length === 0 ) {
return {
redistributed : 0 ,
failed : assignmentsToMove.length ,
failedProjects : assignmentsToMove.map ( ( a ) = > a . project . title ) ,
moves : [ ] as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] ,
}
}
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 } ` ) )
const caps = new Map < string , number > ( )
for ( const juror of candidateJurors ) {
caps . set ( juror . id , juror . maxAssignments ? ? fallbackCap )
}
// Check which candidates have completed all their evaluations
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 candidateMeta = new Map ( candidateJurors . map ( ( j ) = > [ j . id , j ] ) )
type PlannedMove = {
assignmentId : string
projectId : string
projectTitle : string
newJurorId : string
juryGroupId : string | null
isRequired : boolean
}
const plannedMoves : PlannedMove [ ] = [ ]
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 ) = > {
// Prefer jurors who haven't completed all their work
const aLoad = currentLoads . get ( a ) ? ? 0
const bLoad = currentLoads . get ( b ) ? ? 0
const aComplete = aLoad > 0 && ( completedCounts . get ( a ) ? ? 0 ) === aLoad
const bComplete = bLoad > 0 && ( completedCounts . get ( b ) ? ? 0 ) === bLoad
if ( aComplete !== bComplete ) return aComplete ? 1 : - 1
const loadDiff = aLoad - bLoad
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 in transaction with TOCTOU guard
const actualMoves : PlannedMove [ ] = [ ]
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 )
}
} )
}
// Notify destination jurors
if ( actualMoves . length > 0 ) {
const destCounts : Record < string , number > = { }
for ( const move of actualMoves ) {
destCounts [ move . newJurorId ] = ( destCounts [ move . newJurorId ] ? ? 0 ) + 1
}
await createBulkNotifications ( {
userIds : Object.keys ( destCounts ) ,
type : NotificationTypes . BATCH_ASSIGNED ,
title : 'Additional Projects Assigned' ,
message : ` You have received additional project assignments due to a cap adjustment in ${ round . name } . ` ,
linkUrl : ` /jury/competitions ` ,
linkLabel : 'View Assignments' ,
metadata : { roundId : round.id , reason : 'cap_redistribute' } ,
} )
const juror = await ctx . prisma . user . findUnique ( {
where : { id : input.jurorId } ,
select : { name : true , email : true } ,
} )
const jurorName = juror ? . name || juror ? . email || 'Unknown'
const topReceivers = Object . entries ( destCounts )
. map ( ( [ jurorId , count ] ) = > {
const u = candidateMeta . get ( jurorId )
return ` ${ u ? . name || u ? . email || jurorId } ( ${ count } ) `
} )
. join ( ', ' )
await notifyAdmins ( {
type : NotificationTypes . EVALUATION_MILESTONE ,
title : 'Cap Redistribution' ,
message : ` Redistributed ${ actualMoves . length } project(s) from ${ jurorName } (cap lowered to ${ input . newCap } ) to: ${ topReceivers } . ${ failedProjects . length > 0 ? ` ${ failedProjects . length } project(s) could not be reassigned. ` : '' } ` ,
linkUrl : ` /admin/rounds/ ${ round . id } ` ,
linkLabel : 'View Round' ,
metadata : {
roundId : round.id ,
jurorId : input.jurorId ,
newCap : input.newCap ,
movedCount : actualMoves.length ,
failedCount : failedProjects.length ,
} ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'CAP_REDISTRIBUTE' ,
entityType : 'Round' ,
entityId : round.id ,
detailsJson : {
jurorId : input.jurorId ,
jurorName ,
newCap : input.newCap ,
movedCount : actualMoves.length ,
failedCount : failedProjects.length ,
failedProjects ,
moves : actualMoves.map ( ( m ) = > ( {
projectId : m.projectId ,
projectTitle : m.projectTitle ,
newJurorId : m.newJurorId ,
newJurorName : candidateMeta.get ( m . newJurorId ) ? . name || candidateMeta . get ( m . newJurorId ) ? . email || m . newJurorId ,
} ) ) ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
}
return {
redistributed : actualMoves.length ,
failed : failedProjects.length ,
failedProjects ,
moves : actualMoves.map ( ( m ) = > ( {
projectId : m.projectId ,
projectTitle : m.projectTitle ,
newJurorId : m.newJurorId ,
newJurorName : candidateMeta.get ( m . newJurorId ) ? . name || candidateMeta . get ( m . newJurorId ) ? . email || m . newJurorId ,
} ) ) ,
}
} ) ,
2026-02-20 14:18:49 +01:00
/ * *
* Get reshuffle history for a round — shows all dropout / COI reassignment events
* with per - project detail of where each project was moved to .
* /
getReassignmentHistory : adminProcedure
. input ( z . object ( { roundId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
// Get all reshuffle + COI audit entries for this round
const auditEntries = await ctx . prisma . auditLog . findMany ( {
where : {
entityType : { in : [ 'Round' , 'Assignment' ] } ,
2026-02-21 18:50:29 +01:00
action : { in : [ 'JUROR_DROPOUT_RESHUFFLE' , 'COI_REASSIGNMENT' , 'ASSIGNMENT_TRANSFER' , 'CAP_REDISTRIBUTE' ] } ,
2026-02-20 14:18:49 +01:00
entityId : input.roundId ,
} ,
orderBy : { timestamp : 'desc' } ,
include : {
user : { select : { id : true , name : true , email : true } } ,
} ,
} )
// Also get COI reassignment entries that reference this round in detailsJson
const coiEntries = await ctx . prisma . auditLog . findMany ( {
where : {
action : 'COI_REASSIGNMENT' ,
entityType : 'Assignment' ,
} ,
orderBy : { timestamp : 'desc' } ,
include : {
user : { select : { id : true , name : true , email : true } } ,
} ,
} )
// Filter COI entries to this round
const coiForRound = coiEntries . filter ( ( e ) = > {
const details = e . detailsJson as Record < string , unknown > | null
return details ? . roundId === input . roundId
} )
// For retroactive data: find all MANUAL assignments created in this round
// that were created by an admin (not the juror themselves)
const manualAssignments = await ctx . prisma . assignment . findMany ( {
where : {
roundId : input.roundId ,
method : 'MANUAL' ,
createdBy : { not : null } ,
} ,
include : {
user : { select : { id : true , name : true , email : true } } ,
project : { select : { id : true , title : true } } ,
} ,
orderBy : { createdAt : 'desc' } ,
} )
type ReshuffleEvent = {
id : string
2026-02-21 18:50:29 +01:00
type : 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
2026-02-20 14:18:49 +01:00
timestamp : Date
performedBy : { name : string | null ; email : string }
droppedJuror : { id : string ; name : string }
movedCount : number
failedCount : number
failedProjects : string [ ]
moves : { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ]
}
const events : ReshuffleEvent [ ] = [ ]
for ( const entry of auditEntries ) {
const details = entry . detailsJson as Record < string , unknown > | null
if ( ! details ) continue
if ( entry . action === 'JUROR_DROPOUT_RESHUFFLE' ) {
// Check if this entry already has per-move detail (new format)
const moves = ( details . moves as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] ) || [ ]
// If no moves in audit (old format), reconstruct from assignments
let reconstructedMoves = moves
if ( moves . length === 0 && ( details . movedCount as number ) > 0 ) {
// Find MANUAL assignments created around the same time (within 5 seconds)
const eventTime = entry . timestamp . getTime ( )
reconstructedMoves = manualAssignments
. filter ( ( a ) = > {
const diff = Math . abs ( a . createdAt . getTime ( ) - eventTime )
return diff < 5000 && a . createdBy === entry . userId
} )
. map ( ( a ) = > ( {
projectId : a.project.id ,
projectTitle : a.project.title ,
newJurorId : a.user.id ,
newJurorName : a.user.name || a . user . email ,
} ) )
}
events . push ( {
id : entry.id ,
type : 'DROPOUT' ,
timestamp : entry.timestamp ,
performedBy : {
name : entry.user?.name ? ? null ,
email : entry.user?.email ? ? '' ,
} ,
droppedJuror : {
id : details.droppedJurorId as string ,
name : ( details . droppedJurorName as string ) || 'Unknown' ,
} ,
movedCount : ( details . movedCount as number ) || 0 ,
failedCount : ( details . failedCount as number ) || 0 ,
failedProjects : ( details . failedProjects as string [ ] ) || [ ] ,
moves : reconstructedMoves ,
} )
2026-02-21 18:50:29 +01:00
} else if ( entry . action === 'ASSIGNMENT_TRANSFER' ) {
const moves = ( details . moves as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] ) || [ ]
events . push ( {
id : entry.id ,
type : 'TRANSFER' ,
timestamp : entry.timestamp ,
performedBy : {
name : entry.user?.name ? ? null ,
email : entry.user?.email ? ? '' ,
} ,
droppedJuror : {
id : ( details . sourceJurorId as string ) || '' ,
name : ( details . sourceJurorName as string ) || 'Unknown' ,
} ,
movedCount : ( details . movedCount as number ) || 0 ,
failedCount : ( details . failedCount as number ) || 0 ,
failedProjects : ( details . failedProjects as string [ ] ) || [ ] ,
moves ,
} )
} else if ( entry . action === 'CAP_REDISTRIBUTE' ) {
const moves = ( details . moves as { projectId : string ; projectTitle : string ; newJurorId : string ; newJurorName : string } [ ] ) || [ ]
events . push ( {
id : entry.id ,
type : 'CAP_REDISTRIBUTE' ,
timestamp : entry.timestamp ,
performedBy : {
name : entry.user?.name ? ? null ,
email : entry.user?.email ? ? '' ,
} ,
droppedJuror : {
id : ( details . jurorId as string ) || '' ,
name : ( details . jurorName as string ) || 'Unknown' ,
} ,
movedCount : ( details . movedCount as number ) || 0 ,
failedCount : ( details . failedCount as number ) || 0 ,
failedProjects : ( details . failedProjects as string [ ] ) || [ ] ,
moves ,
} )
2026-02-20 14:18:49 +01:00
}
}
// Process COI entries
for ( const entry of coiForRound ) {
const details = entry . detailsJson as Record < string , unknown > | null
if ( ! details ) continue
// Look up project title
const project = details . projectId
? await ctx . prisma . project . findUnique ( {
where : { id : details.projectId as string } ,
select : { title : true } ,
} )
: null
// Look up new juror name
const newJuror = details . newJurorId
? await ctx . prisma . user . findUnique ( {
where : { id : details.newJurorId as string } ,
select : { name : true , email : true } ,
} )
: null
// Look up old juror name
const oldJuror = details . oldJurorId
? await ctx . prisma . user . findUnique ( {
where : { id : details.oldJurorId as string } ,
select : { name : true , email : true } ,
} )
: null
events . push ( {
id : entry.id ,
type : 'COI' ,
timestamp : entry.timestamp ,
performedBy : {
name : entry.user?.name ? ? null ,
email : entry.user?.email ? ? '' ,
} ,
droppedJuror : {
id : ( details . oldJurorId as string ) || '' ,
name : oldJuror?.name || oldJuror ? . email || 'Unknown' ,
} ,
movedCount : 1 ,
failedCount : 0 ,
failedProjects : [ ] ,
moves : [ {
projectId : ( details . projectId as string ) || '' ,
projectTitle : project?.title || 'Unknown' ,
newJurorId : ( details . newJurorId as string ) || '' ,
newJurorName : newJuror?.name || newJuror ? . email || 'Unknown' ,
} ] ,
} )
}
// Sort all events by timestamp descending
events . sort ( ( a , b ) = > b . timestamp . getTime ( ) - a . timestamp . getTime ( ) )
return events
} ) ,
2026-02-14 15:26:42 +01:00
} )