Add per-round assignment constraints (min/max per judge)
Build and Push Docker Image / build (push) Successful in 9m41s Details

- 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:
Matt 2026-02-04 16:01:18 +01:00
parent ff26769ce1
commit 6d2537ec04
8 changed files with 209 additions and 50 deletions

View File

@ -0,0 +1,3 @@
-- Add bio field to User model for judge/mentor profile descriptions
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "bio" TEXT;

View File

@ -0,0 +1,4 @@
-- Add assignment constraint fields to Round model
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "minAssignmentsPerJuror" INTEGER NOT NULL DEFAULT 5;
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "maxAssignmentsPerJuror" INTEGER NOT NULL DEFAULT 20;

View File

@ -357,6 +357,8 @@ model Round {
// Configuration // Configuration
requiredReviews Int @default(3) // Min evaluations per project requiredReviews Int @default(3) // Min evaluations per project
minAssignmentsPerJuror Int @default(5) // Target minimum projects per judge
maxAssignmentsPerJuror Int @default(20) // Max projects per judge in this round
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc. settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -81,7 +81,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId }) const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId }) const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery( const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery(
{ roundId, maxPerJuror: 10, minPerProject: 3 }, { roundId },
{ enabled: !!round } { enabled: !!round }
) )

View File

@ -59,6 +59,8 @@ const updateRoundSchema = z
.object({ .object({
name: z.string().min(1, 'Name is required').max(255), name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10), requiredReviews: z.number().int().min(1).max(10),
minAssignmentsPerJuror: z.number().int().min(1).max(50),
maxAssignmentsPerJuror: z.number().int().min(1).max(100),
votingStartAt: z.date().nullable().optional(), votingStartAt: z.date().nullable().optional(),
votingEndAt: z.date().nullable().optional(), votingEndAt: z.date().nullable().optional(),
}) })
@ -74,6 +76,13 @@ const updateRoundSchema = z
path: ['votingEndAt'], path: ['votingEndAt'],
} }
) )
.refine(
(data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror,
{
message: 'Min must be less than or equal to max',
path: ['minAssignmentsPerJuror'],
}
)
type UpdateRoundForm = z.infer<typeof updateRoundSchema> type UpdateRoundForm = z.infer<typeof updateRoundSchema>
@ -121,6 +130,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
defaultValues: { defaultValues: {
name: '', name: '',
requiredReviews: 3, requiredReviews: 3,
minAssignmentsPerJuror: 5,
maxAssignmentsPerJuror: 20,
votingStartAt: null, votingStartAt: null,
votingEndAt: null, votingEndAt: null,
}, },
@ -132,6 +143,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
form.reset({ form.reset({
name: round.name, name: round.name,
requiredReviews: round.requiredReviews, requiredReviews: round.requiredReviews,
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null, votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
}) })
@ -161,6 +174,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
id: roundId, id: roundId,
name: data.name, name: data.name,
requiredReviews: data.requiredReviews, requiredReviews: data.requiredReviews,
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType, roundType,
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null, votingStartAt: data.votingStartAt ?? null,
@ -277,6 +292,58 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</FormItem> </FormItem>
)} )}
/> />
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="minAssignmentsPerJuror"
render={({ field }) => (
<FormItem>
<FormLabel>Min Projects per Judge</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={50}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Target minimum projects each judge should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxAssignmentsPerJuror"
render={({ field }) => (
<FormItem>
<FormLabel>Max Projects per Judge</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={100}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Maximum projects a judge can be assigned
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

@ -70,6 +70,8 @@ export const roundRouter = router({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'), roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'),
requiredReviews: z.number().int().min(1).max(10).default(3), requiredReviews: z.number().int().min(1).max(10).default(3),
minAssignmentsPerJuror: z.number().int().min(1).max(50).default(5),
maxAssignmentsPerJuror: z.number().int().min(1).max(100).default(20),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
settingsJson: z.record(z.unknown()).optional(), settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(), votingStartAt: z.date().optional(),
@ -78,6 +80,14 @@ export const roundRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Validate assignment constraints
if (input.minAssignmentsPerJuror > input.maxAssignmentsPerJuror) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Min assignments per juror must be less than or equal to max',
})
}
// Validate dates // Validate dates
if (input.votingStartAt && input.votingEndAt) { if (input.votingStartAt && input.votingEndAt) {
if (input.votingEndAt <= input.votingStartAt) { if (input.votingEndAt <= input.votingStartAt) {
@ -154,6 +164,8 @@ export const roundRouter = router({
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional().nullable(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional().nullable(),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).optional(), roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).optional(),
requiredReviews: z.number().int().min(1).max(10).optional(), requiredReviews: z.number().int().min(1).max(10).optional(),
minAssignmentsPerJuror: z.number().int().min(1).max(50).optional(),
maxAssignmentsPerJuror: z.number().int().min(1).max(100).optional(),
submissionDeadline: z.date().optional().nullable(), submissionDeadline: z.date().optional().nullable(),
votingStartAt: z.date().optional().nullable(), votingStartAt: z.date().optional().nullable(),
votingEndAt: z.date().optional().nullable(), votingEndAt: z.date().optional().nullable(),
@ -174,6 +186,22 @@ export const roundRouter = router({
} }
} }
// Validate assignment constraints if either is provided
if (data.minAssignmentsPerJuror !== undefined || data.maxAssignmentsPerJuror !== undefined) {
const existingRound = await ctx.prisma.round.findUnique({
where: { id },
select: { minAssignmentsPerJuror: true, maxAssignmentsPerJuror: true, status: true },
})
const newMin = data.minAssignmentsPerJuror ?? existingRound?.minAssignmentsPerJuror ?? 5
const newMax = data.maxAssignmentsPerJuror ?? existingRound?.maxAssignmentsPerJuror ?? 20
if (newMin > newMax) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Min assignments per juror must be less than or equal to max',
})
}
}
// Check if we should auto-activate (if voting start is in the past and round is DRAFT) // Check if we should auto-activate (if voting start is in the past and round is DRAFT)
const now = new Date() const now = new Date()
let autoActivate = false let autoActivate = false

View File

@ -78,6 +78,7 @@ interface ProjectForAssignment {
interface AssignmentConstraints { interface AssignmentConstraints {
requiredReviewsPerProject: number requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number maxAssignmentsPerJuror?: number
existingAssignments: Array<{ existingAssignments: Array<{
jurorId: string jurorId: string
@ -412,18 +413,22 @@ export function generateFallbackAssignments(
return true return true
}) })
.map((juror) => ({ .map((juror) => {
const currentLoad = jurorAssignments.get(juror.id) || 0
const maxLoad = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 20
const minTarget = constraints.minAssignmentsPerJuror ?? 5
return {
juror, juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags), score: calculateExpertiseScore(juror.expertiseTags, project.tags),
loadScore: calculateLoadScore( loadScore: calculateLoadScore(currentLoad, maxLoad),
jurorAssignments.get(juror.id) || 0, underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10 }
), })
}))
.sort((a, b) => { .sort((a, b) => {
// Combined score: 60% expertise, 40% load balancing // Combined score: 50% expertise, 30% load balancing, 20% under-min bonus
const aTotal = a.score * 0.6 + a.loadScore * 0.4 const aTotal = a.score * 0.5 + a.loadScore * 0.3 + a.underMinBonus * 0.2
const bTotal = b.score * 0.6 + b.loadScore * 0.4 const bTotal = b.score * 0.5 + b.loadScore * 0.3 + b.underMinBonus * 0.2
return bTotal - aTotal return bTotal - aTotal
}) })
@ -494,6 +499,16 @@ function calculateLoadScore(currentLoad: number, maxLoad: number): number {
return Math.max(0, 1 - utilization) return Math.max(0, 1 - utilization)
} }
/**
* Calculate bonus for jurors under their minimum target
* Returns 1.0 if under min, scaled down as approaching min
*/
function calculateUnderMinBonus(currentLoad: number, minTarget: number): number {
if (currentLoad >= minTarget) return 0
// Scale bonus based on how far under min (1.0 at 0 load, decreasing as approaching min)
return (minTarget - currentLoad) / minTarget
}
/** /**
* Generate reasoning for fallback assignments * Generate reasoning for fallback assignments
*/ */