Add per-round assignment constraints (min/max per judge)
Build and Push Docker Image / build (push) Successful in 9m41s
Details
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:
parent
ff26769ce1
commit
6d2537ec04
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -356,8 +356,10 @@ model Round {
|
||||||
votingEndAt DateTime?
|
votingEndAt DateTime?
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
requiredReviews Int @default(3) // Min evaluations per project
|
requiredReviews Int @default(3) // Min evaluations per project
|
||||||
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
where: { id: input.userId },
|
ctx.prisma.round.findUniqueOrThrow({
|
||||||
select: { maxAssignments: true },
|
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) {
|
// Check if at or over limit
|
||||||
const currentCount = await ctx.prisma.assignment.count({
|
if (currentCount >= effectiveMax) {
|
||||||
where: { userId: input.userId, roundId: input.roundId },
|
if (!input.forceOverride) {
|
||||||
})
|
|
||||||
|
|
||||||
if (currentCount >= user.maxAssignments) {
|
|
||||||
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(
|
||||||
|
`Under target: ${juror._count.assignments}/${round.minAssignmentsPerJuror} min`
|
||||||
|
)
|
||||||
|
}
|
||||||
reasoning.push(
|
reasoning.push(
|
||||||
`Workload: ${juror._count.assignments}/${maxAllowed} assigned`
|
`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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
juror,
|
const currentLoad = jurorAssignments.get(juror.id) || 0
|
||||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
const maxLoad = juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 20
|
||||||
loadScore: calculateLoadScore(
|
const minTarget = constraints.minAssignmentsPerJuror ?? 5
|
||||||
jurorAssignments.get(juror.id) || 0,
|
|
||||||
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
|
return {
|
||||||
),
|
juror,
|
||||||
}))
|
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
||||||
|
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
||||||
|
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
||||||
|
}
|
||||||
|
})
|
||||||
.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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue