Files
MOPC-Portal/src/server/routers/assignment.ts
Matt 1c78ecf21d refactor: tech debt batch 2 — drop dead models, stale columns, schema cleanup
Schema:
- Drop 4 dead models: OverrideAction, NotificationPolicy, AssignmentException, AdvancementRule
- Drop 2 dead enums: OverrideReasonCode, AdvancementRuleType
- Drop 3 stale columns: Project.roundId, ConflictOfInterest.roundId, Evaluation.version
- Remove 3 back-relation fields from User, Assignment, Round

Code:
- Fix 6 COI queries in assignment.ts + 1 in juror-reassignment.ts
  (roundId filter → assignment.roundId after column drop)
- Remove orphaned Project.roundId write in project.ts createProject
- Remove advancementRules include from round.ts getById
- Remove AdvancementRule from RoundWithRelations type
- Clean up seed.ts (remove advancement rule seeding)
- Clean up tests/helpers.ts (remove dead model cleanup)
- Add TODO comments on user delete mutations (FK violation risk)

Migration: 20260308000000_drop_dead_models_and_stale_columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:35:23 +01:00

2776 lines
98 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure, userHasRole, withAIRateLimit } 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'
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../services/juror-reassignment'
export { reassignAfterCOI, reassignDroppedJurorAssignments }
/** Evaluation statuses that are safe to move (not yet finalized). */
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
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<string, unknown>
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: {
roles: { has: '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: {
assignment: { 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<string, number> = {}
for (const juror of jurors) {
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
jurorLimits[juror.id] = juror.maxAssignments
}
}
const constraints = {
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
maxAssignmentsPerJuror,
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
const result = await generateAIAssignments(
jurors,
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,
criterionScoresJson: true,
form: { select: { criteriaJson: 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<string, unknown> = {
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 (
userHasRole(ctx.user, 'JURY_MEMBER') &&
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') &&
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<string, unknown>
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<string, unknown>
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<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
// Filter out assignments that would exceed a juror's limit
let skippedDueToCapacity = 0
const allowedAssignments = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true // unknown user, let createMany handle it
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
// Increment running count for subsequent assignments to same user
runningCounts.set(a.userId, currentCount + 1)
return true
})
const result = await ctx.prisma.assignment.createMany({
data: allowedAssignments.map((a) => ({
...a,
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<string, number>
)
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
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<string, unknown>
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<string, unknown>
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<string, { min: number; max: number }> | 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: {
roles: { has: '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<string, Record<string, number>>()
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<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
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<string, number>
)
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<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
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<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
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<string, number>
)
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<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
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
.use(withAIRateLimit)
.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<string, number> = {}
for (const a of assignments) {
userCounts[a.userId] = (userCounts[a.userId] || 0) + 1
}
const deadline = round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
// Create in-app notifications grouped by project count
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
let totalSent = 0
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
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<string, unknown>
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 }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' },
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: { assignment: { 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<string, number>()
for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
const caps = new Map<string, number>()
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<string, string[]> = {}
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<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Fetch requested assignments — must belong to source juror
const requestedAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: input.assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
},
select: {
id: true,
projectId: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
})
// Filter to movable only
const assignments = requestedAssignments.map((a) => ({
id: a.id,
projectId: a.projectId,
projectTitle: a.project.title,
evalStatus: a.evaluation?.status ?? null,
movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
}))
const movableProjectIds = assignments
.filter((a) => a.movable)
.map((a) => a.projectId)
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.sourceJurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.sourceJurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
const candidateIds = candidateJurors.map((j) => j.id)
// Existing assignments, loads, COI pairs
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
// Completed evaluations count per candidate
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
const uid = e.assignment.userId
completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
assignment: { 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<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Verify all assignments belong to source juror and are movable
const assignmentIds = input.transfers.map((t) => t.assignmentId)
const sourceAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
project: { select: { title: true } },
},
})
const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))
// Build candidate pool data
const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
const destinationUsers = await ctx.prisma.user.findMany({
where: { id: { in: destinationIds } },
select: { id: true, name: true, email: true, maxAssignments: true },
})
const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
assignment: { 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<string, string[]> = {}
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<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
const jurorAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId, userId: input.jurorId },
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
createdAt: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
orderBy: { createdAt: 'desc' },
})
const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
if (overCapCount === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Separate movable and immovable, pick the newest movable ones for redistribution
const movable = jurorAssignments.filter(
(a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
)
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
movable.sort((a, b) => {
const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
if (diff !== 0) return diff
return b.createdAt.getTime() - a.createdAt.getTime()
})
const assignmentsToMove = movable.slice(0, overCapCount)
if (assignmentsToMove.length === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.jurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
return {
redistributed: 0,
failed: assignmentsToMove.length,
failedProjects: assignmentsToMove.map((a) => a.project.title),
moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
}
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
assignment: { roundId: input.roundId },
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
const caps = new Map<string, number>()
for (const juror of candidateJurors) {
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
}
// Check which candidates have completed all their evaluations
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
}
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
type PlannedMove = {
assignmentId: string
projectId: string
projectTitle: string
newJurorId: string
juryGroupId: string | null
isRequired: boolean
}
const plannedMoves: PlannedMove[] = []
const failedProjects: string[] = []
for (const assignment of assignmentsToMove) {
const eligible = candidateIds
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
.sort((a, b) => {
// Prefer jurors who haven't completed all their work
const aLoad = currentLoads.get(a) ?? 0
const bLoad = currentLoads.get(b) ?? 0
const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
if (aComplete !== bComplete) return aComplete ? 1 : -1
const loadDiff = aLoad - bLoad
if (loadDiff !== 0) return loadDiff
return a.localeCompare(b)
})
if (eligible.length === 0) {
failedProjects.push(assignment.project.title)
continue
}
const selectedJurorId = eligible[0]
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
newJurorId: selectedJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
}
// Execute in transaction with TOCTOU guard
const actualMoves: PlannedMove[] = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: input.jurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
failedProjects.push(move.projectTitle)
continue
}
await tx.assignment.create({
data: {
roundId: input.roundId,
projectId: move.projectId,
userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
actualMoves.push(move)
}
})
}
// Notify destination jurors
if (actualMoves.length > 0) {
const destCounts: Record<string, number> = {}
for (const move of actualMoves) {
destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
}
await createBulkNotifications({
userIds: Object.keys(destCounts),
type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects Assigned',
message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'cap_redistribute' },
})
const juror = await ctx.prisma.user.findUnique({
where: { id: input.jurorId },
select: { name: true, email: true },
})
const jurorName = juror?.name || juror?.email || 'Unknown'
const topReceivers = Object.entries(destCounts)
.map(([jurorId, count]) => {
const u = candidateMeta.get(jurorId)
return `${u?.name || u?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Cap Redistribution',
message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
jurorId: input.jurorId,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CAP_REDISTRIBUTE',
entityType: 'Round',
entityId: round.id,
detailsJson: {
jurorId: input.jurorId,
jurorName,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return {
redistributed: actualMoves.length,
failed: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
}
}),
/**
* 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<string, unknown> | null
return details?.roundId === input.roundId
})
// For retroactive data: find all MANUAL assignments created in this round
// that were created by an admin (not the juror themselves)
const manualAssignments = await ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
method: 'MANUAL',
createdBy: { not: null },
},
include: {
user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
})
type ReshuffleEvent = {
id: string
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<string, unknown> | null
if (!details) continue
if (entry.action === 'JUROR_DROPOUT_RESHUFFLE') {
// Check if this entry already has per-move detail (new format)
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
// If no moves in audit (old format), reconstruct from assignments
let reconstructedMoves = moves
if (moves.length === 0 && (details.movedCount as number) > 0) {
// Find MANUAL assignments created around the same time (within 5 seconds)
const eventTime = entry.timestamp.getTime()
reconstructedMoves = manualAssignments
.filter((a) => {
const diff = Math.abs(a.createdAt.getTime() - eventTime)
return diff < 5000 && a.createdBy === entry.userId
})
.map((a) => ({
projectId: a.project.id,
projectTitle: a.project.title,
newJurorId: a.user.id,
newJurorName: a.user.name || a.user.email,
}))
}
events.push({
id: entry.id,
type: 'DROPOUT',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: details.droppedJurorId as string,
name: (details.droppedJurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves: reconstructedMoves,
})
} 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<string, unknown> | null
if (!details) continue
// Look up project title
const project = details.projectId
? await ctx.prisma.project.findUnique({
where: { id: details.projectId as string },
select: { title: true },
})
: null
// Look up new juror name
const newJuror = details.newJurorId
? await ctx.prisma.user.findUnique({
where: { id: details.newJurorId as string },
select: { name: true, email: true },
})
: null
// Look up old juror name
const oldJuror = details.oldJurorId
? await ctx.prisma.user.findUnique({
where: { id: details.oldJurorId as string },
select: { name: true, email: true },
})
: null
events.push({
id: entry.id,
type: 'COI',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.oldJurorId as string) || '',
name: oldJuror?.name || oldJuror?.email || 'Unknown',
},
movedCount: 1,
failedCount: 0,
failedProjects: [],
moves: [{
projectId: (details.projectId as string) || '',
projectTitle: project?.title || 'Unknown',
newJurorId: (details.newJurorId as string) || '',
newJurorName: newJuror?.name || newJuror?.email || 'Unknown',
}],
})
}
// Sort all events by timestamp descending
events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
return events
}),
})