diff --git a/prisma/migrations/20260204140000_add_user_bio/migration.sql b/prisma/migrations/20260204140000_add_user_bio/migration.sql new file mode 100644 index 0000000..2d1e2ff --- /dev/null +++ b/prisma/migrations/20260204140000_add_user_bio/migration.sql @@ -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; + diff --git a/prisma/migrations/20260204150000_add_assignment_constraints/migration.sql b/prisma/migrations/20260204150000_add_assignment_constraints/migration.sql new file mode 100644 index 0000000..c74f658 --- /dev/null +++ b/prisma/migrations/20260204150000_add_assignment_constraints/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a22fe8..3035ed5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -356,8 +356,10 @@ model Round { votingEndAt DateTime? // Configuration - requiredReviews Int @default(3) // Min evaluations per project - settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc. + 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. createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx index bbade0d..5c2c1a1 100644 --- a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx @@ -81,7 +81,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId }) const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId }) const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery( - { roundId, maxPerJuror: 10, minPerProject: 3 }, + { roundId }, { enabled: !!round } ) diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 73bf9dd..07b99de 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -59,6 +59,8 @@ const updateRoundSchema = z .object({ name: z.string().min(1, 'Name is required').max(255), 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(), votingEndAt: z.date().nullable().optional(), }) @@ -74,6 +76,13 @@ const updateRoundSchema = z path: ['votingEndAt'], } ) + .refine( + (data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror, + { + message: 'Min must be less than or equal to max', + path: ['minAssignmentsPerJuror'], + } + ) type UpdateRoundForm = z.infer @@ -121,6 +130,8 @@ function EditRoundContent({ roundId }: { roundId: string }) { defaultValues: { name: '', requiredReviews: 3, + minAssignmentsPerJuror: 5, + maxAssignmentsPerJuror: 20, votingStartAt: null, votingEndAt: null, }, @@ -132,6 +143,8 @@ function EditRoundContent({ roundId }: { roundId: string }) { form.reset({ name: round.name, requiredReviews: round.requiredReviews, + minAssignmentsPerJuror: round.minAssignmentsPerJuror, + maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null, }) @@ -161,6 +174,8 @@ function EditRoundContent({ roundId }: { roundId: string }) { id: roundId, name: data.name, requiredReviews: data.requiredReviews, + minAssignmentsPerJuror: data.minAssignmentsPerJuror, + maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, roundType, settingsJson: roundSettings, votingStartAt: data.votingStartAt ?? null, @@ -277,6 +292,58 @@ function EditRoundContent({ roundId }: { roundId: string }) { )} /> + +
+ ( + + Min Projects per Judge + + + field.onChange(parseInt(e.target.value) || 1) + } + /> + + + Target minimum projects each judge should receive + + + + )} + /> + + ( + + Max Projects per Judge + + + field.onChange(parseInt(e.target.value) || 1) + } + /> + + + Maximum projects a judge can be assigned + + + + )} + /> +
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 9044793..638e1ac 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -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, diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index aa6d597..377bb68 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -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 diff --git a/src/server/services/ai-assignment.ts b/src/server/services/ai-assignment.ts index 36e2097..555f32f 100644 --- a/src/server/services/ai-assignment.ts +++ b/src/server/services/ai-assignment.ts @@ -78,6 +78,7 @@ interface ProjectForAssignment { interface AssignmentConstraints { requiredReviewsPerProject: number + minAssignmentsPerJuror?: number maxAssignmentsPerJuror?: number existingAssignments: Array<{ jurorId: string @@ -412,18 +413,22 @@ export function generateFallbackAssignments( return true }) - .map((juror) => ({ - juror, - score: calculateExpertiseScore(juror.expertiseTags, project.tags), - loadScore: calculateLoadScore( - jurorAssignments.get(juror.id) || 0, - juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10 - ), - })) + .map((juror) => { + const currentLoad = jurorAssignments.get(juror.id) || 0 + const maxLoad = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 20 + const minTarget = constraints.minAssignmentsPerJuror ?? 5 + + return { + juror, + score: calculateExpertiseScore(juror.expertiseTags, project.tags), + loadScore: calculateLoadScore(currentLoad, maxLoad), + underMinBonus: calculateUnderMinBonus(currentLoad, minTarget), + } + }) .sort((a, b) => { - // Combined score: 60% expertise, 40% load balancing - const aTotal = a.score * 0.6 + a.loadScore * 0.4 - const bTotal = b.score * 0.6 + b.loadScore * 0.4 + // Combined score: 50% expertise, 30% load balancing, 20% under-min bonus + const aTotal = a.score * 0.5 + a.loadScore * 0.3 + a.underMinBonus * 0.2 + const bTotal = b.score * 0.5 + b.loadScore * 0.3 + b.underMinBonus * 0.2 return bTotal - aTotal }) @@ -494,6 +499,16 @@ function calculateLoadScore(currentLoad: number, maxLoad: number): number { 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 */