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

@@ -70,6 +70,8 @@ export const roundRouter = router({
name: z.string().min(1).max(255),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'),
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(),
settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
@@ -78,6 +80,14 @@ export const roundRouter = router({
})
)
.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
if (input.votingStartAt && input.votingEndAt) {
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(),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).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(),
votingStartAt: 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)
const now = new Date()
let autoActivate = false