Add per-round assignment constraints (min/max per judge)

- Add minAssignmentsPerJuror and maxAssignmentsPerJuror fields to Round model
- Update assignment router:
  - Calculate effective max from user override or round default
  - Add forceOverride parameter for manual assignment beyond limits
  - Update getSuggestions to use round constraints with min target bonus
  - Update getAISuggestions to pass constraints to AI service
- Update AI assignment service:
  - Add minAssignmentsPerJuror to constraints interface
  - Update fallback algorithm with under-min bonus scoring
  - New score weights: 50% expertise, 30% load, 20% under-min bonus
- Update round router:
  - Add new constraint fields to create/update schemas
  - Add validation for min <= max constraint
- Update admin UI:
  - Add min/max constraint fields to round edit page
  - Remove hardcoded maxPerJuror from assignments page
- Add migration files for production deployment:
  - User.bio field for judge/mentor profiles
  - Round assignment constraint fields

Constraint hierarchy:
1. User.maxAssignments (if set) overrides round default
2. Round.maxAssignmentsPerJuror is the default cap
3. Admin can force-override any limit with confirmation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 16:01:18 +01:00
parent ff26769ce1
commit 6d2537ec04
8 changed files with 209 additions and 50 deletions

View File

@@ -137,6 +137,7 @@ export const assignmentRouter = router({
projectId: z.string(),
roundId: z.string(),
isRequired: z.boolean().default(true),
forceOverride: z.boolean().default(false), // Allow manual override of limits
})
)
.mutation(async ({ ctx, input }) => {
@@ -158,28 +159,41 @@ export const assignmentRouter = router({
})
}
// Check user's assignment limit
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: { maxAssignments: true },
// Get round constraints and user limit
const [round, user] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { maxAssignmentsPerJuror: true },
}),
ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: { maxAssignments: true, name: true },
}),
])
// Calculate effective max: user override takes precedence if set
const effectiveMax = user.maxAssignments ?? round.maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, roundId: input.roundId },
})
if (user.maxAssignments !== null) {
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, roundId: input.roundId },
})
if (currentCount >= user.maxAssignments) {
// Check if at or over limit
if (currentCount >= effectiveMax) {
if (!input.forceOverride) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User has reached their maximum assignment limit of ${user.maxAssignments}`,
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: {
...input,
...assignmentData,
method: 'MANUAL',
createdBy: ctx.user.id,
},
@@ -199,7 +213,7 @@ export const assignmentRouter = router({
})
// Send notification to the assigned jury member
const [project, round] = await Promise.all([
const [project, roundInfo] = await Promise.all([
ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { title: true },
@@ -210,9 +224,9 @@ export const assignmentRouter = router({
}),
])
if (project && round) {
const deadline = round.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
if (project && roundInfo) {
const deadline = roundInfo.votingEndAt
? new Date(roundInfo.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -224,12 +238,12 @@ export const assignmentRouter = router({
userId: input.userId,
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${round.name}.`,
message: `You have been assigned to evaluate "${project.title}" for ${roundInfo.name}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
roundName: round.name,
roundName: roundInfo.name,
deadline,
assignmentId: assignment.id,
},
@@ -419,11 +433,19 @@ export const assignmentRouter = router({
.input(
z.object({
roundId: z.string(),
maxPerJuror: z.number().int().min(1).max(50).default(10),
minPerProject: z.number().int().min(1).max(10).default(3),
})
)
.query(async ({ ctx, input }) => {
// Get round constraints
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
},
})
// Get all active jury members with their expertise and current load
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
@@ -473,25 +495,25 @@ export const assignmentRouter = router({
for (const project of projects) {
// Skip if project has enough assignments
if (project._count.assignments >= input.minPerProject) continue
if (project._count.assignments >= round.requiredReviews) continue
const neededAssignments = input.minPerProject - project._count.assignments
const neededAssignments = round.requiredReviews - project._count.assignments
// Score each juror for this project
const jurorScores = jurors
.filter((j) => {
// Skip if already assigned
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
// Skip if at max capacity
const maxAllowed = j.maxAssignments ?? input.maxPerJuror
if (j._count.assignments >= maxAllowed) return false
// Skip if at max capacity (user override takes precedence)
const effectiveMax = j.maxAssignments ?? round.maxAssignmentsPerJuror
if (j._count.assignments >= effectiveMax) return false
return true
})
.map((juror) => {
const reasoning: string[] = []
let score = 0
// Expertise match (40% weight)
// Expertise match (35% weight)
const matchingTags = juror.expertiseTags.filter((tag) =>
project.tags.includes(tag)
)
@@ -499,17 +521,31 @@ export const assignmentRouter = router({
matchingTags.length > 0
? matchingTags.length / Math.max(project.tags.length, 1)
: 0
score += expertiseScore * 40
score += expertiseScore * 35
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
}
// Load balancing (25% weight)
const maxAllowed = juror.maxAssignments ?? input.maxPerJuror
const loadScore = 1 - juror._count.assignments / maxAllowed
score += loadScore * 25
// Load balancing (20% weight)
const effectiveMax = juror.maxAssignments ?? round.maxAssignmentsPerJuror
const loadScore = 1 - juror._count.assignments / effectiveMax
score += loadScore * 20
// Under min target bonus (15% weight) - prioritize judges who need more projects
const underMinBonus =
juror._count.assignments < round.minAssignmentsPerJuror
? (round.minAssignmentsPerJuror - juror._count.assignments) * 3
: 0
score += Math.min(15, underMinBonus)
// Build reasoning
if (juror._count.assignments < round.minAssignmentsPerJuror) {
reasoning.push(
`Under target: ${juror._count.assignments}/${round.minAssignmentsPerJuror} min`
)
}
reasoning.push(
`Workload: ${juror._count.assignments}/${maxAllowed} assigned`
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
)
return {
@@ -546,14 +582,17 @@ export const assignmentRouter = router({
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
maxPerJuror: z.number().int().min(1).max(50).default(10),
})
)
.query(async ({ ctx, input }) => {
// Get round info
// Get round constraints
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
select: {
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
},
})
// Get all active jury members with their expertise and current load
@@ -594,7 +633,8 @@ export const assignmentRouter = router({
const constraints = {
requiredReviewsPerProject: round.requiredReviews,
maxAssignmentsPerJuror: input.maxPerJuror,
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,