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' /** * Reassign a project after a juror declares COI. * Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment. * Returns the new juror info or null if no eligible juror found. */ export async function reassignAfterCOI(params: { assignmentId: string auditUserId?: string auditIp?: string auditUserAgent?: string }): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> { const assignment = await prisma.assignment.findUnique({ where: { id: params.assignmentId }, include: { round: { select: { id: true, name: true, configJson: true, juryGroupId: true } }, project: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } }, }, }) if (!assignment) return null const { roundId, projectId } = assignment const config = (assignment.round.configJson ?? {}) as Record const maxAssignmentsPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // ── Build exclusion set: jurors who must NEVER get this project ────────── // 1. Currently assigned to this project in ANY round (not just current) const allProjectAssignments = await prisma.assignment.findMany({ where: { projectId }, select: { userId: true }, }) const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId)) // 2. COI records for this project (any juror who declared conflict, ever) const coiRecords = await prisma.conflictOfInterest.findMany({ where: { projectId, hasConflict: true }, select: { userId: true }, }) for (const c of coiRecords) excludedUserIds.add(c.userId) // 3. Historical: jurors who previously had this project but were removed // (via COI reassignment or admin transfer — tracked in audit logs) const historicalAuditLogs = await prisma.decisionAuditLog.findMany({ where: { eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] }, detailsJson: { path: ['projectId'], equals: projectId }, }, select: { detailsJson: true }, }) for (const log of historicalAuditLogs) { const details = log.detailsJson as Record | null if (!details) continue // COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string) // ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string) // Transfer logs may have a moves array with per-project details if (Array.isArray(details.moves)) { for (const move of details.moves as Array>) { if (move.projectId === projectId && move.newJurorId) { // The juror who received via past transfer also had it excludedUserIds.add(move.newJurorId as string) } } } } // ── Find candidate jurors ─────────────────────────────────────────────── let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[] if (assignment.round.juryGroupId) { const members = await prisma.juryGroupMember.findMany({ where: { juryGroupId: assignment.round.juryGroupId }, include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } }, }) candidateJurors = members .filter((m) => m.user.status === 'ACTIVE') .map((m) => m.user) } else { // No jury group — scope to jurors already assigned to this round const roundJurorIds = await prisma.assignment.findMany({ where: { roundId }, select: { userId: true }, distinct: ['userId'], }) const activeRoundJurorIds = roundJurorIds.map((a) => a.userId) candidateJurors = activeRoundJurorIds.length > 0 ? await prisma.user.findMany({ where: { id: { in: activeRoundJurorIds }, role: 'JURY_MEMBER', status: 'ACTIVE', }, select: { id: true, name: true, email: true, maxAssignments: true }, }) : [] } // Filter out all excluded jurors (current assignments, COI, historical) const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id)) if (eligible.length === 0) return null // ── Score eligible jurors: prefer those with incomplete evaluations ────── const eligibleIds = eligible.map((j) => j.id) // Get assignment counts and evaluation completion for eligible jurors in this round const roundAssignments = await prisma.assignment.findMany({ where: { roundId, userId: { in: eligibleIds } }, select: { userId: true, evaluation: { select: { status: true } } }, }) // Build per-juror stats: total assignments, completed evaluations const jurorStats = new Map() for (const a of roundAssignments) { const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 } stats.total++ if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') { stats.completed++ } jurorStats.set(a.userId, stats) } // Rank jurors: under cap, then prefer those still working (completed < total) const ranked = eligible .map((j) => { const stats = jurorStats.get(j.id) || { total: 0, completed: 0 } const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror const hasIncomplete = stats.completed < stats.total return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete } }) .filter((j) => j.currentCount < j.effectiveMax) .sort((a, b) => { // 1. Prefer jurors with incomplete evaluations (still active) if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1 // 2. Then fewest current assignments (load balancing) return a.currentCount - b.currentCount }) if (ranked.length === 0) return null const replacement = ranked[0] // Delete old assignment and create replacement atomically. // Cascade deletes COI record and any draft evaluation. const newAssignment = await prisma.$transaction(async (tx) => { await tx.assignment.delete({ where: { id: params.assignmentId } }) return tx.assignment.create({ data: { userId: replacement.id, projectId, roundId, juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined, isRequired: assignment.isRequired, method: 'MANUAL', }, }) }) // Notify the replacement juror (COI-specific notification) await createNotification({ userId: replacement.id, type: NotificationTypes.COI_REASSIGNED, title: 'Project Reassigned to You (COI)', message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignment', metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name }, }) // Notify admins of the reassignment await notifyAdmins({ type: NotificationTypes.EVALUATION_MILESTONE, title: 'COI Auto-Reassignment', message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`, linkUrl: `/admin/rounds/${roundId}`, linkLabel: 'View Round', metadata: { projectId, oldJurorId: assignment.userId, newJurorId: replacement.id, reason: 'COI', }, }) // Audit if (params.auditUserId) { await logAudit({ prisma, userId: params.auditUserId, action: 'COI_REASSIGNMENT', entityType: 'Assignment', entityId: newAssignment.id, detailsJson: { oldAssignmentId: params.assignmentId, oldJurorId: assignment.userId, newJurorId: replacement.id, projectId, roundId, }, ipAddress: params.auditIp, userAgent: params.auditUserAgent, }) } return { newJurorId: replacement.id, newJurorName: replacement.name || replacement.email, newAssignmentId: newAssignment.id, } } /** Evaluation statuses that are safe to move (not yet finalized). */ const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const async function reassignDroppedJurorAssignments(params: { roundId: string droppedJurorId: string auditUserId?: string auditIp?: string auditUserAgent?: string }) { const round = await prisma.round.findUnique({ where: { id: params.roundId }, select: { id: true, name: true, configJson: true, juryGroupId: true }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' }) } const droppedJuror = await prisma.user.findUnique({ where: { id: params.droppedJurorId }, select: { id: true, name: true, email: true }, }) if (!droppedJuror) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' }) } const config = (round.configJson ?? {}) as Record const fallbackCap = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // Only pick assignments with no evaluation or evaluation still in draft/not-started. // Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched. const assignmentsToMove = await prisma.assignment.findMany({ where: { roundId: params.roundId, userId: params.droppedJurorId, OR: [ { evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, select: { id: true, projectId: true, juryGroupId: true, isRequired: true, createdAt: true, project: { select: { title: true } }, }, orderBy: { createdAt: 'asc' }, }) if (assignmentsToMove.length === 0) { return { movedCount: 0, failedCount: 0, failedProjects: [] as string[], reassignedTo: {} as Record, } } let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[] if (round.juryGroupId) { const members = await prisma.juryGroupMember.findMany({ where: { juryGroupId: round.juryGroupId }, include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true, }, }, }, }) candidateJurors = members .filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId) .map((m) => m.user) } else { // No jury group configured — scope to jurors already assigned to this round // (the de facto jury pool). This prevents assigning to random JURY_MEMBER // accounts that aren't part of this round's jury. const roundJurorIds = await prisma.assignment.findMany({ where: { roundId: params.roundId }, select: { userId: true }, distinct: ['userId'], }) const activeRoundJurorIds = roundJurorIds .map((a) => a.userId) .filter((id) => id !== params.droppedJurorId) candidateJurors = activeRoundJurorIds.length > 0 ? await prisma.user.findMany({ where: { id: { in: activeRoundJurorIds }, role: 'JURY_MEMBER', status: 'ACTIVE', }, select: { id: true, name: true, email: true, maxAssignments: true }, }) : [] } if (candidateJurors.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' }) } const candidateIds = candidateJurors.map((j) => j.id) const existingAssignments = await prisma.assignment.findMany({ where: { roundId: params.roundId }, select: { userId: true, projectId: true }, }) const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`)) const currentLoads = new Map() for (const a of existingAssignments) { currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1) } const coiRecords = await prisma.conflictOfInterest.findMany({ where: { roundId: params.roundId, hasConflict: true, userId: { in: candidateIds }, }, select: { userId: true, projectId: true }, }) const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`)) const caps = new Map() for (const juror of candidateJurors) { caps.set(juror.id, juror.maxAssignments ?? fallbackCap) } const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j])) const plannedMoves: { assignmentId: string projectId: string projectTitle: string newJurorId: string juryGroupId: string | null isRequired: boolean }[] = [] const failedProjects: string[] = [] for (const assignment of assignmentsToMove) { const eligible = candidateIds .filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`)) .filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`)) .filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap)) .sort((a, b) => { const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0) if (loadDiff !== 0) return loadDiff return a.localeCompare(b) }) if (eligible.length === 0) { failedProjects.push(assignment.project.title) continue } const selectedJurorId = eligible[0] plannedMoves.push({ assignmentId: assignment.id, projectId: assignment.projectId, projectTitle: assignment.project.title, newJurorId: selectedJurorId, juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired, }) alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`) currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1) } // Execute moves inside a transaction with per-move TOCTOU guard. // Uses conditional deleteMany so a concurrent evaluation submission // (which sets status to SUBMITTED) causes the delete to return count=0 // instead of cascade-destroying the submitted evaluation. const actualMoves: typeof plannedMoves = [] const skippedProjects: string[] = [] if (plannedMoves.length > 0) { await prisma.$transaction(async (tx) => { for (const move of plannedMoves) { // Guard: only delete if the assignment still belongs to the dropped juror // AND its evaluation (if any) is still in a movable state. // If a juror submitted between our read and now, count will be 0. const deleted = await tx.assignment.deleteMany({ where: { id: move.assignmentId, userId: params.droppedJurorId, OR: [ { evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, }) if (deleted.count === 0) { // Assignment was already moved, deleted, or its evaluation was submitted skippedProjects.push(move.projectTitle) continue } await tx.assignment.create({ data: { roundId: params.roundId, projectId: move.projectId, userId: move.newJurorId, juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired, method: 'MANUAL', createdBy: params.auditUserId ?? undefined, }, }) actualMoves.push(move) } }) } // Add skipped projects to the failed list failedProjects.push(...skippedProjects) const reassignedTo: Record = {} for (const move of actualMoves) { reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1 } if (actualMoves.length > 0) { // Build per-juror project name lists for proper emails const destProjectNames: Record = {} for (const move of actualMoves) { if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = [] destProjectNames[move.newJurorId].push(move.projectTitle) } const droppedName = droppedJuror.name || droppedJuror.email // Fetch round deadline for email const roundFull = await prisma.round.findUnique({ where: { id: params.roundId }, select: { windowCloseAt: true }, }) const deadline = roundFull?.windowCloseAt ? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt) : undefined for (const [jurorId, projectNames] of Object.entries(destProjectNames)) { const count = projectNames.length await createNotification({ userId: jurorId, type: NotificationTypes.DROPOUT_REASSIGNED, title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`, message: count === 1 ? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.` : `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' }, }) } const topReceivers = Object.entries(reassignedTo) .map(([jurorId, count]) => { const juror = candidateMeta.get(jurorId) return `${juror?.name || juror?.email || jurorId} (${count})` }) .join(', ') await notifyAdmins({ type: NotificationTypes.EVALUATION_MILESTONE, title: 'Juror Dropout Reshuffle', message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`, linkUrl: `/admin/rounds/${round.id}`, linkLabel: 'View Round', metadata: { roundId: round.id, droppedJurorId: droppedJuror.id, movedCount: actualMoves.length, failedCount: failedProjects.length, topReceivers, }, }) } // Remove the dropped juror from the jury group so they can't be re-assigned // in future assignment runs for this round's competition. let removedFromGroup = false if (round.juryGroupId) { const deleted = await prisma.juryGroupMember.deleteMany({ where: { juryGroupId: round.juryGroupId, userId: params.droppedJurorId, }, }) removedFromGroup = deleted.count > 0 } if (params.auditUserId) { // Build per-project move detail for audit trail const moveDetails = actualMoves.map((move) => { const juror = candidateMeta.get(move.newJurorId) return { projectId: move.projectId, projectTitle: move.projectTitle, newJurorId: move.newJurorId, newJurorName: juror?.name || juror?.email || move.newJurorId, } }) await logAudit({ prisma, userId: params.auditUserId, action: 'JUROR_DROPOUT_RESHUFFLE', entityType: 'Round', entityId: round.id, detailsJson: { droppedJurorId: droppedJuror.id, droppedJurorName: droppedJuror.name || droppedJuror.email, movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects, skippedProjects, reassignedTo, removedFromGroup, moves: moveDetails, }, ipAddress: params.auditIp, userAgent: params.auditUserAgent, }) } return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects, reassignedTo, } } async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) { try { await prisma.assignmentJob.update({ where: { id: jobId }, data: { status: 'RUNNING', startedAt: new Date() }, }) const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { name: true, configJson: true, competitionId: true, juryGroupId: true, }, }) const config = (round.configJson ?? {}) as Record const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3 const minAssignmentsPerJuror = (config.minLoadPerJuror as number) ?? (config.minAssignmentsPerJuror as number) ?? 1 const maxAssignmentsPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // 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) } const jurors = await prisma.user.findMany({ where: { role: 'JURY_MEMBER', status: 'ACTIVE', ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), }, select: { id: true, name: true, email: true, expertiseTags: true, maxAssignments: true, _count: { select: { assignments: { where: { roundId } }, }, }, }, }) const projectRoundStates = await prisma.projectRoundState.findMany({ where: { roundId }, select: { projectId: true }, }) const projectIds = projectRoundStates.map((prs) => prs.projectId) const projects = await prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, description: true, tags: true, teamName: true, projectTags: { select: { tag: { select: { name: true } }, confidence: true }, }, _count: { select: { assignments: { where: { roundId } } } }, }, }) // 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, })), })) const existingAssignments = await prisma.assignment.findMany({ where: { roundId }, select: { userId: true, projectId: true }, }) // 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}`) ) // 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 = {} 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, projectsWithConfidence, constraints, userId, roundId, onProgress ) // 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 // Enrich suggestions with names for storage const enrichedSuggestions = filteredSuggestions.map((s) => { 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, suggestionsCount: filteredSuggestions.length, suggestionsJson: enrichedSuggestions, fallbackUsed: result.fallbackUsed ?? false, }, }) await notifyAdmins({ type: NotificationTypes.AI_SUGGESTIONS_READY, title: 'AI Assignment Suggestions Ready', message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`, linkUrl: `/admin/rounds/${roundId}`, linkLabel: 'View Suggestions', priority: 'high', metadata: { roundId, jobId, projectCount: projects.length, suggestionsCount: filteredSuggestions.length, 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 .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, 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 } }, conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } }, }, 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({ roundId: z.string().optional(), status: z.enum(['all', 'pending', 'completed']).default('all'), }) ) .query(async ({ ctx, input }) => { const where: Record = { userId: ctx.user.id, } if (input.roundId) { where.roundId = input.roundId } 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 }, }, round: true, 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 } }, round: { include: { evaluationForms: { where: { isActive: true } } } }, 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(), roundId: z.string(), isRequired: z.boolean().default(true), forceOverride: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.assignment.findUnique({ where: { userId_projectId_roundId: { userId: input.userId, projectId: input.projectId, roundId: input.roundId, }, }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'This assignment already exists', }) } const [stage, user] = await Promise.all([ ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true }, }), ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, select: { maxAssignments: true, name: true }, }), ]) const config = (stage.configJson ?? {}) as Record const maxAssignmentsPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror const currentCount = await ctx.prisma.assignment.count({ where: { userId: input.userId, roundId: input.roundId }, }) // 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 }, }), ctx.prisma.round.findUnique({ where: { id: input.roundId }, 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}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignment', metadata: { projectName: project.title, roundName: stageInfo.name, deadline, assignmentId: assignment.id, }, }) } return assignment }), /** * Bulk create assignments (admin only) */ bulkCreate: adminProcedure .input( z.object({ roundId: z.string(), 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: { assignments: { where: { roundId: input.roundId } }, }, }, }, }) const userMap = new Map(users.map((u) => [u.id, u])) // Get stage default max const stage = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true, name: true, windowCloseAt: true }, }) const config = (stage.configJson ?? {}) as Record const stageMaxPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 // Track running counts to handle multiple assignments to the same juror in one batch const runningCounts = new Map() 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, roundId: input.roundId, 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 ) const deadline = stage?.windowCloseAt ? new Date(stage.windowCloseAt).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }) : undefined const usersByProjectCount = new Map() 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'}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { projectCount, roundName: stage?.name, 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 .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const stage = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true }, }) const config = (stage.configJson ?? {}) as Record const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3 const projectRoundStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId: input.roundId }, select: { projectId: true }, }) const projectIds = projectRoundStates.map((pss) => pss.projectId) const [ totalAssignments, completedAssignments, assignmentsByUser, projectCoverage, ] = await Promise.all([ ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId, isCompleted: true }, }), ctx.prisma.assignment.groupBy({ by: ['userId'], where: { roundId: input.roundId }, _count: true, }), ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, _count: { select: { assignments: { where: { roundId: input.roundId } } } }, }, }), ]) 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({ roundId: z.string(), }) ) .query(async ({ ctx, input }) => { const stage = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true, juryGroupId: true }, }) const config = (stage.configJson ?? {}) as Record const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3 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 | undefined) : undefined // 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) } const jurors = await ctx.prisma.user.findMany({ where: { role: 'JURY_MEMBER', status: 'ACTIVE', ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), }, select: { id: true, name: true, email: true, expertiseTags: true, maxAssignments: true, _count: { select: { assignments: { where: { roundId: input.roundId } }, }, }, }, }) const projectRoundStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId: input.roundId }, select: { projectId: true }, }) const projectIds = projectRoundStates.map((pss) => pss.projectId) const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, tags: true, competitionCategory: true, projectTags: { include: { tag: { select: { name: true } } }, }, _count: { select: { assignments: { where: { roundId: input.roundId } } } }, }, }) const existingAssignments = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, select: { userId: true, projectId: true }, }) const assignmentSet = new Set( existingAssignments.map((a) => `${a.userId}-${a.projectId}`) ) // Build per-juror category distribution for quota scoring const jurorCategoryDistribution = new Map>() if (categoryQuotas) { const assignmentsWithCategory = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, 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 } } 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` ) // 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)`) } } } } } 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({ roundId: z.string(), useAI: z.boolean().default(true), }) ) .query(async ({ ctx, input }) => { const completedJob = await ctx.prisma.assignmentJob.findFirst({ where: { roundId: input.roundId, 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({ where: { roundId: input.roundId }, 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({ roundId: z.string(), 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: { assignments: { where: { roundId: input.roundId } }, }, }, }, }) const userMap = new Map(users.map((u) => [u.id, u])) const stageData = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true }, }) const config = (stageData.configJson ?? {}) as Record const stageMaxPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 const runningCounts = new Map() 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, roundId: input.roundId, 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: { roundId: input.roundId, 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 ) const stage = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, 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() 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'}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { projectCount, roundName: stage?.name, deadline, }, }) } } return { created: created.count, requested: input.assignments.length, skippedDueToCapacity, } }), /** * Apply suggested assignments */ applySuggestions: adminProcedure .input( z.object({ roundId: z.string(), 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: { assignments: { where: { roundId: input.roundId } }, }, }, }, }) const userMap = new Map(users.map((u) => [u.id, u])) const stageData = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true }, }) const config = (stageData.configJson ?? {}) as Record const stageMaxPerJuror = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 const runningCounts = new Map() 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, roundId: input.roundId, 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: { roundId: input.roundId, 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 ) const stage = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, 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() 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'}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { projectCount, roundName: stage?.name, deadline, }, }) } } return { created: created.count, requested: input.assignments.length, skippedDueToCapacity, } }), /** * Start an AI assignment job (background processing) */ startAIAssignmentJob: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const existingJob = await ctx.prisma.assignmentJob.findFirst({ where: { roundId: input.roundId, 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: { roundId: input.roundId, status: 'PENDING', }, }) runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error) 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 .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const job = await ctx.prisma.assignmentJob.findFirst({ where: { roundId: input.roundId }, 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, } }), /** * Notify all jurors of their current assignments for a round (admin only). * Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications). */ 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) { return { sent: 0, jurorCount: 0 } } // Count assignments per user const userCounts: Record = {} 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() 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', metadata: { projectCount, roundName: round.name, deadline }, }) 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, }) return { sent: totalSent, jurorCount: Object.keys(userCounts).length } }), 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 } }), 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 }), 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, }) }), /** * Redistribute all movable assignments from a juror to other jurors (without dropping them from the group). * Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group. * Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors. */ redistributeJurorAssignments: adminProcedure .input(z.object({ roundId: z.string(), jurorId: z.string() })) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true }, }) const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.jurorId }, select: { id: true, name: true, email: true }, }) const config = (round.configJson ?? {}) as Record const fallbackCap = (config.maxLoadPerJuror as number) ?? (config.maxAssignmentsPerJuror as number) ?? 20 const assignmentsToMove = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId, userId: input.jurorId, OR: [ { evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }, ], }, select: { id: true, projectId: true, juryGroupId: true, isRequired: true, project: { select: { title: true } }, }, orderBy: { createdAt: 'asc' }, }) if (assignmentsToMove.length === 0) { return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] } } // Build candidate pool let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[] if (round.juryGroupId) { const members = await ctx.prisma.juryGroupMember.findMany({ where: { juryGroupId: round.juryGroupId }, include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } }, }) candidateJurors = members.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId).map((m) => m.user) } else { const roundJurorIds = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, select: { userId: true }, distinct: ['userId'], }) const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId) candidateJurors = ids.length > 0 ? await ctx.prisma.user.findMany({ where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' }, select: { id: true, name: true, email: true, maxAssignments: true }, }) : [] } if (candidateJurors.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' }) } const candidateIds = candidateJurors.map((j) => j.id) const existingAssignments = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, select: { userId: true, projectId: true }, }) const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`)) const currentLoads = new Map() for (const a of existingAssignments) currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1) const coiRecords = await ctx.prisma.conflictOfInterest.findMany({ where: { roundId: input.roundId, hasConflict: true, userId: { in: candidateIds } }, select: { userId: true, projectId: true }, }) const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`)) // Completed eval counts for "prefer not-finished" logic const completedEvals = await ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId, userId: { in: candidateIds } }, status: 'SUBMITTED' }, select: { assignment: { select: { userId: true } } }, }) const completedCounts = new Map() for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1) const caps = new Map() for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap) const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = [] const failedProjects: string[] = [] for (const assignment of assignmentsToMove) { // First pass: prefer jurors who haven't completed all evals let eligible = candidateIds .filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`)) .filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`)) .filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap)) // Sort: prefer not-all-completed, then lowest load eligible.sort((a, b) => { const loadA = currentLoads.get(a) ?? 0 const loadB = currentLoads.get(b) ?? 0 const compA = completedCounts.get(a) ?? 0 const compB = completedCounts.get(b) ?? 0 const doneA = loadA > 0 && compA === loadA ? 1 : 0 const doneB = loadB > 0 && compB === loadB ? 1 : 0 if (doneA !== doneB) return doneA - doneB return loadA - loadB }) if (eligible.length === 0) { failedProjects.push(assignment.project.title) continue } const selectedId = eligible[0] plannedMoves.push({ assignmentId: assignment.id, projectId: assignment.projectId, projectTitle: assignment.project.title, newJurorId: selectedId, juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired, }) alreadyAssigned.add(`${selectedId}:${assignment.projectId}`) currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1) } // Execute in transaction const actualMoves: typeof plannedMoves = [] if (plannedMoves.length > 0) { await ctx.prisma.$transaction(async (tx) => { for (const move of plannedMoves) { const deleted = await tx.assignment.deleteMany({ where: { id: move.assignmentId, userId: input.jurorId, OR: [{ evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }], }, }) if (deleted.count === 0) { failedProjects.push(move.projectTitle); continue } await tx.assignment.create({ data: { roundId: input.roundId, projectId: move.projectId, userId: move.newJurorId, juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired, method: 'MANUAL', createdBy: ctx.user.id, }, }) actualMoves.push(move) } }) } // Send MANUAL_REASSIGNED emails per destination juror if (actualMoves.length > 0) { const destProjectNames: Record = {} for (const move of actualMoves) { if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = [] destProjectNames[move.newJurorId].push(move.projectTitle) } const deadline = round.windowCloseAt ? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt) : undefined for (const [jurorId, projectNames] of Object.entries(destProjectNames)) { const count = projectNames.length await createNotification({ userId: jurorId, type: NotificationTypes.MANUAL_REASSIGNED, title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`, message: count === 1 ? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.` : `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' }, }) } const sourceName = sourceJuror.name || sourceJuror.email const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j])) const topReceivers = Object.entries(destProjectNames) .map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` }) .join(', ') await notifyAdmins({ type: NotificationTypes.EVALUATION_MILESTONE, title: 'Assignment Redistribution', message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`, linkUrl: `/admin/rounds/${round.id}`, linkLabel: 'View Round', metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE', entityType: 'Round', entityId: round.id, detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) } return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects } }), /** * Get transfer candidates: which of the source juror's assignments can be moved, * and which other jurors are eligible to receive them. */ 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 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() 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() 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 ) // Track which movable projects this candidate already has assigned const alreadyAssignedProjectIds = movableProjectIds.filter((pid) => alreadyAssigned.has(`${j.id}:${pid}`) ) return { userId: j.id, name: j.name || j.email, email: j.email, currentLoad: load, cap, allCompleted, eligibleProjectIds, alreadyAssignedProjectIds, } }) // 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 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() 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 with per-juror project names if (actualMoves.length > 0) { const destMoves: Record = {} for (const move of actualMoves) { if (!destMoves[move.destinationJurorId]) destMoves[move.destinationJurorId] = [] destMoves[move.destinationJurorId].push(move.projectTitle) } for (const [jurorId, projectNames] of Object.entries(destMoves)) { const count = projectNames.length await createNotification({ userId: jurorId, type: NotificationTypes.MANUAL_REASSIGNED, title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`, message: count === 1 ? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.` : `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`, linkUrl: `/jury/competitions`, linkLabel: 'View Assignments', metadata: { roundId: round.id, roundName: round.name, projectNames, reason: 'admin_transfer' }, }) } // Notify admins const sourceJuror = await ctx.prisma.user.findUnique({ where: { id: input.sourceJurorId }, select: { name: true, email: true }, }) const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown' const topReceivers = Object.entries(destMoves) .map(([jurorId, projects]) => { const u = destUserMap.get(jurorId) return `${u?.name || u?.email || jurorId} (${projects.length})` }) .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 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() 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() 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() 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 = {} 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, })), } }), /** * 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'] }, action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] }, 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 | 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 type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE' 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 | 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, }) } 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, }) } } // Process COI entries for (const entry of coiForRound) { const details = entry.detailsJson as Record | 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 }), })