diff --git a/src/components/admin/pipeline/stage-panels/filter-panel.tsx b/src/components/admin/pipeline/stage-panels/filter-panel.tsx
index 8399d52..b7a8bfb 100644
--- a/src/components/admin/pipeline/stage-panels/filter-panel.tsx
+++ b/src/components/admin/pipeline/stage-panels/filter-panel.tsx
@@ -122,11 +122,16 @@ export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
)}
{config?.aiRubricEnabled && (
-
+
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
+ {config.aiCriteriaText && (
+
+ Criteria: {config.aiCriteriaText}
+
+ )}
)}
diff --git a/src/components/admin/pipeline/stage-panels/intake-panel.tsx b/src/components/admin/pipeline/stage-panels/intake-panel.tsx
index e5197dd..00181a1 100644
--- a/src/components/admin/pipeline/stage-panels/intake-panel.tsx
+++ b/src/components/admin/pipeline/stage-panels/intake-panel.tsx
@@ -1,5 +1,7 @@
'use client'
+import Link from 'next/link'
+import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -111,15 +113,18 @@ export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
) : (
{projectStates.items.map((ps) => (
-
- {ps.project.title}
-
- {ps.state}
-
-
+
+ {ps.project.title}
+
+ {ps.state}
+
+
+
))}
)}
diff --git a/src/components/admin/pipeline/wizard-section.tsx b/src/components/admin/pipeline/wizard-section.tsx
index 5e15ab2..b07dc22 100644
--- a/src/components/admin/pipeline/wizard-section.tsx
+++ b/src/components/admin/pipeline/wizard-section.tsx
@@ -8,11 +8,12 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Badge } from '@/components/ui/badge'
-import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
+import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react'
type WizardSectionProps = {
title: string
description?: string
+ helpText?: string
stepNumber: number
isOpen: boolean
onToggle: () => void
@@ -24,6 +25,7 @@ type WizardSectionProps = {
export function WizardSection({
title,
description,
+ helpText,
stepNumber,
isOpen,
onToggle,
@@ -74,7 +76,15 @@ export function WizardSection({
- {children}
+
+ {helpText && (
+
+
+ {helpText}
+
+ )}
+ {children}
+
diff --git a/src/components/ui/info-tooltip.tsx b/src/components/ui/info-tooltip.tsx
new file mode 100644
index 0000000..fce309a
--- /dev/null
+++ b/src/components/ui/info-tooltip.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import { Info } from 'lucide-react'
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+ TooltipProvider,
+} from '@/components/ui/tooltip'
+
+type InfoTooltipProps = {
+ content: string
+ side?: 'top' | 'right' | 'bottom' | 'left'
+}
+
+export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
+ return (
+
+
+
+
+
+
+ {content}
+
+
+
+ )
+}
diff --git a/src/lib/file-type-categories.ts b/src/lib/file-type-categories.ts
new file mode 100644
index 0000000..9605bb6
--- /dev/null
+++ b/src/lib/file-type-categories.ts
@@ -0,0 +1,29 @@
+export type FileTypeCategory = {
+ id: string
+ label: string
+ mimeTypes: string[]
+ extensions: string[]
+}
+
+export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
+ { id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
+ { id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
+ { id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
+ { id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
+ { id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
+ { id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
+]
+
+/** Get active category IDs from a list of mime types */
+export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
+ return FILE_TYPE_CATEGORIES.filter((cat) =>
+ cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
+ ).map((cat) => cat.id)
+}
+
+/** Convert category IDs to flat mime type array */
+export function categoriesToMimeTypes(categoryIds: string[]): string[] {
+ return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
+ (cat) => cat.mimeTypes
+ )
+}
diff --git a/src/lib/pipeline-defaults.ts b/src/lib/pipeline-defaults.ts
index 6cb84cc..4580118 100644
--- a/src/lib/pipeline-defaults.ts
+++ b/src/lib/pipeline-defaults.ts
@@ -31,6 +31,7 @@ export function defaultFilterConfig(): FilterConfig {
return {
rules: [],
aiRubricEnabled: false,
+ aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true,
}
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index 3647887..bf8e439 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
+import { normalizeCountryToCode } from '@/lib/countries'
const editionOrStageInput = z.object({
stageId: z.string().optional(),
@@ -384,9 +385,16 @@ export const analyticsRouter = router({
_count: { id: true },
})
- return distribution.map((d) => ({
- countryCode: d.country || 'UNKNOWN',
- count: d._count.id,
+ // Resolve country names to ISO codes (DB may store "France" instead of "FR")
+ const codeMap = new Map
()
+ for (const d of distribution) {
+ const resolved = normalizeCountryToCode(d.country) ?? d.country ?? 'UNKNOWN'
+ codeMap.set(resolved, (codeMap.get(resolved) ?? 0) + d._count.id)
+ }
+
+ return Array.from(codeMap.entries()).map(([countryCode, count]) => ({
+ countryCode,
+ count,
}))
}),
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts
index 68a1a7b..382c481 100644
--- a/src/server/routers/assignment.ts
+++ b/src/server/routers/assignment.ts
@@ -96,10 +96,19 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
})
}
+ // Build per-juror limits map for jurors with personal maxAssignments
+ const jurorLimits: Record = {}
+ for (const juror of jurors) {
+ if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
+ jurorLimits[juror.id] = juror.maxAssignments
+ }
+ }
+
const constraints = {
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
maxAssignmentsPerJuror,
+ jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
@@ -420,8 +429,58 @@ export const assignmentRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
+ // Fetch per-juror maxAssignments and current counts for capacity checking
+ const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
+ const users = await ctx.prisma.user.findMany({
+ where: { id: { in: uniqueUserIds } },
+ select: {
+ id: true,
+ name: true,
+ maxAssignments: true,
+ _count: {
+ select: {
+ assignments: { where: { stageId: input.stageId } },
+ },
+ },
+ },
+ })
+ const userMap = new Map(users.map((u) => [u.id, u]))
+
+ // Get stage default max
+ const stage = await ctx.prisma.stage.findUniqueOrThrow({
+ where: { id: input.stageId },
+ select: { configJson: true, name: true, windowCloseAt: true },
+ })
+ const config = (stage.configJson ?? {}) as Record
+ const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
+
+ // Track running counts to handle multiple assignments to the same juror in one batch
+ const runningCounts = new Map()
+ for (const u of users) {
+ runningCounts.set(u.id, u._count.assignments)
+ }
+
+ // Filter out assignments that would exceed a juror's limit
+ let skippedDueToCapacity = 0
+ const allowedAssignments = input.assignments.filter((a) => {
+ const user = userMap.get(a.userId)
+ if (!user) return true // unknown user, let createMany handle it
+
+ const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
+ const currentCount = runningCounts.get(a.userId) ?? 0
+
+ if (currentCount >= effectiveMax) {
+ skippedDueToCapacity++
+ return false
+ }
+
+ // Increment running count for subsequent assignments to same user
+ runningCounts.set(a.userId, currentCount + 1)
+ return true
+ })
+
const result = await ctx.prisma.assignment.createMany({
- data: input.assignments.map((a) => ({
+ data: allowedAssignments.map((a) => ({
...a,
stageId: input.stageId,
method: 'BULK',
@@ -436,15 +495,19 @@ export const assignmentRouter = router({
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'Assignment',
- detailsJson: { count: result.count },
+ detailsJson: {
+ count: result.count,
+ requested: input.assignments.length,
+ skippedDueToCapacity,
+ },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send notifications to assigned jury members (grouped by user)
- if (result.count > 0 && input.assignments.length > 0) {
+ if (result.count > 0 && allowedAssignments.length > 0) {
// Group assignments by user to get counts
- const userAssignmentCounts = input.assignments.reduce(
+ const userAssignmentCounts = allowedAssignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
@@ -452,11 +515,6 @@ export const assignmentRouter = router({
{} as Record
)
- const stage = await ctx.prisma.stage.findUnique({
- where: { id: input.stageId },
- select: { name: true, windowCloseAt: true },
- })
-
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
@@ -495,6 +553,7 @@ export const assignmentRouter = router({
created: result.count,
requested: input.assignments.length,
skipped: input.assignments.length - result.count,
+ skippedDueToCapacity,
}
}),
@@ -826,11 +885,61 @@ export const assignmentRouter = router({
})
),
usedAI: z.boolean().default(false),
+ forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
+ let assignmentsToCreate = input.assignments
+ let skippedDueToCapacity = 0
+
+ // Capacity check (unless forceOverride)
+ if (!input.forceOverride) {
+ const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
+ const users = await ctx.prisma.user.findMany({
+ where: { id: { in: uniqueUserIds } },
+ select: {
+ id: true,
+ maxAssignments: true,
+ _count: {
+ select: {
+ assignments: { where: { stageId: input.stageId } },
+ },
+ },
+ },
+ })
+ const userMap = new Map(users.map((u) => [u.id, u]))
+
+ const stageData = await ctx.prisma.stage.findUniqueOrThrow({
+ where: { id: input.stageId },
+ select: { configJson: true },
+ })
+ const config = (stageData.configJson ?? {}) as Record
+ const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
+
+ const runningCounts = new Map()
+ for (const u of users) {
+ runningCounts.set(u.id, u._count.assignments)
+ }
+
+ assignmentsToCreate = input.assignments.filter((a) => {
+ const user = userMap.get(a.userId)
+ if (!user) return true
+
+ const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
+ const currentCount = runningCounts.get(a.userId) ?? 0
+
+ if (currentCount >= effectiveMax) {
+ skippedDueToCapacity++
+ return false
+ }
+
+ runningCounts.set(a.userId, currentCount + 1)
+ return true
+ })
+ }
+
const created = await ctx.prisma.assignment.createMany({
- data: input.assignments.map((a) => ({
+ data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
@@ -852,13 +961,15 @@ export const assignmentRouter = router({
stageId: input.stageId,
count: created.count,
usedAI: input.usedAI,
+ forceOverride: input.forceOverride,
+ skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
- const userAssignmentCounts = input.assignments.reduce(
+ const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
@@ -905,7 +1016,11 @@ export const assignmentRouter = router({
}
}
- return { created: created.count }
+ return {
+ created: created.count,
+ requested: input.assignments.length,
+ skippedDueToCapacity,
+ }
}),
/**
@@ -922,11 +1037,61 @@ export const assignmentRouter = router({
reasoning: z.string().optional(),
})
),
+ forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
+ let assignmentsToCreate = input.assignments
+ let skippedDueToCapacity = 0
+
+ // Capacity check (unless forceOverride)
+ if (!input.forceOverride) {
+ const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
+ const users = await ctx.prisma.user.findMany({
+ where: { id: { in: uniqueUserIds } },
+ select: {
+ id: true,
+ maxAssignments: true,
+ _count: {
+ select: {
+ assignments: { where: { stageId: input.stageId } },
+ },
+ },
+ },
+ })
+ const userMap = new Map(users.map((u) => [u.id, u]))
+
+ const stageData = await ctx.prisma.stage.findUniqueOrThrow({
+ where: { id: input.stageId },
+ select: { configJson: true },
+ })
+ const config = (stageData.configJson ?? {}) as Record
+ const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
+
+ const runningCounts = new Map()
+ for (const u of users) {
+ runningCounts.set(u.id, u._count.assignments)
+ }
+
+ assignmentsToCreate = input.assignments.filter((a) => {
+ const user = userMap.get(a.userId)
+ if (!user) return true
+
+ const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
+ const currentCount = runningCounts.get(a.userId) ?? 0
+
+ if (currentCount >= effectiveMax) {
+ skippedDueToCapacity++
+ return false
+ }
+
+ runningCounts.set(a.userId, currentCount + 1)
+ return true
+ })
+ }
+
const created = await ctx.prisma.assignment.createMany({
- data: input.assignments.map((a) => ({
+ data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
@@ -945,13 +1110,15 @@ export const assignmentRouter = router({
detailsJson: {
stageId: input.stageId,
count: created.count,
+ forceOverride: input.forceOverride,
+ skippedDueToCapacity,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (created.count > 0) {
- const userAssignmentCounts = input.assignments.reduce(
+ const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
@@ -998,7 +1165,11 @@ export const assignmentRouter = router({
}
}
- return { created: created.count }
+ return {
+ created: created.count,
+ requested: input.assignments.length,
+ skippedDueToCapacity,
+ }
}),
/**
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index 6386b2b..b4a9ed9 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -696,6 +696,132 @@ export const fileRouter = router({
return results
}),
+ /**
+ * Get file requirements for a project from its pipeline's intake stage.
+ * Returns both configJson-based requirements and actual FileRequirement records,
+ * along with which ones are already fulfilled by uploaded files.
+ */
+ getProjectRequirements: adminProcedure
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ // 1. Get the project and its program
+ const project = await ctx.prisma.project.findUniqueOrThrow({
+ where: { id: input.projectId },
+ select: { programId: true },
+ })
+
+ // 2. Find the pipeline for this program
+ const pipeline = await ctx.prisma.pipeline.findFirst({
+ where: { programId: project.programId },
+ include: {
+ tracks: {
+ where: { kind: 'MAIN' },
+ include: {
+ stages: {
+ where: { stageType: 'INTAKE' },
+ take: 1,
+ },
+ },
+ },
+ },
+ })
+
+ if (!pipeline) return null
+
+ const mainTrack = pipeline.tracks[0]
+ if (!mainTrack) return null
+
+ const intakeStage = mainTrack.stages[0]
+ if (!intakeStage) return null
+
+ // 3. Check for actual FileRequirement records first
+ const dbRequirements = await ctx.prisma.fileRequirement.findMany({
+ where: { stageId: intakeStage.id },
+ orderBy: { sortOrder: 'asc' },
+ include: {
+ files: {
+ where: { projectId: input.projectId },
+ select: {
+ id: true,
+ fileName: true,
+ fileType: true,
+ mimeType: true,
+ size: true,
+ createdAt: true,
+ },
+ },
+ },
+ })
+
+ // 4. If we have DB requirements, return those (they're the canonical source)
+ if (dbRequirements.length > 0) {
+ return {
+ stageId: intakeStage.id,
+ requirements: dbRequirements.map((req) => ({
+ id: req.id,
+ name: req.name,
+ description: req.description,
+ acceptedMimeTypes: req.acceptedMimeTypes,
+ maxSizeMB: req.maxSizeMB,
+ isRequired: req.isRequired,
+ fulfilled: req.files.length > 0,
+ fulfilledFile: req.files[0] ?? null,
+ })),
+ }
+ }
+
+ // 5. Fall back to configJson requirements
+ const configJson = intakeStage.configJson as Record | null
+ const fileRequirements = (configJson?.fileRequirements as Array<{
+ name: string
+ description?: string
+ acceptedMimeTypes?: string[]
+ maxSizeMB?: number
+ isRequired?: boolean
+ type?: string
+ required?: boolean
+ }>) ?? []
+
+ if (fileRequirements.length === 0) return null
+
+ // 6. Get project files to check fulfillment
+ const projectFiles = await ctx.prisma.projectFile.findMany({
+ where: { projectId: input.projectId },
+ select: {
+ id: true,
+ fileName: true,
+ fileType: true,
+ mimeType: true,
+ size: true,
+ createdAt: true,
+ },
+ })
+
+ return {
+ stageId: intakeStage.id,
+ requirements: fileRequirements.map((req) => {
+ const reqName = req.name.toLowerCase()
+ // Match by checking if any uploaded file's fileName contains the requirement name
+ const matchingFile = projectFiles.find((f) =>
+ f.fileName.toLowerCase().includes(reqName) ||
+ reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
+ )
+
+ return {
+ id: null as string | null,
+ name: req.name,
+ description: req.description ?? null,
+ acceptedMimeTypes: req.acceptedMimeTypes ?? [],
+ maxSizeMB: req.maxSizeMB ?? null,
+ // Handle both formats: isRequired (wizard type) and required (seed data)
+ isRequired: req.isRequired ?? req.required ?? false,
+ fulfilled: !!matchingFile,
+ fulfilledFile: matchingFile ?? null,
+ }
+ }),
+ }
+ }),
+
// =========================================================================
// FILE REQUIREMENTS
// =========================================================================
diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts
index 4bfde5f..add29f2 100644
--- a/src/server/routers/pipeline.ts
+++ b/src/server/routers/pipeline.ts
@@ -316,7 +316,7 @@ export const pipelineRouter = router({
createStructure: adminProcedure
.input(
z.object({
- programId: z.string(),
+ programId: z.string().min(1, 'Program ID is required'),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
settingsJson: z.record(z.unknown()).optional(),
diff --git a/src/server/routers/stageAssignment.ts b/src/server/routers/stageAssignment.ts
index cd1cf42..fc8c456 100644
--- a/src/server/routers/stageAssignment.ts
+++ b/src/server/routers/stageAssignment.ts
@@ -267,22 +267,41 @@ export const stageAssignmentRouter = router({
_count: true,
})
+ // Fetch per-juror maxAssignments for all jurors involved
+ const allJurorIds = jurorLoads.map((j) => j.userId)
+ const jurorUsers = await ctx.prisma.user.findMany({
+ where: { id: { in: allJurorIds } },
+ select: { id: true, maxAssignments: true },
+ })
+ const jurorMaxMap = new Map(jurorUsers.map((u) => [u.id, u.maxAssignments]))
+
const overLoaded = jurorLoads.filter(
(j) => j._count > input.targetPerJuror
)
- const underLoaded = jurorLoads.filter(
- (j) => j._count < input.targetPerJuror
- )
- // Calculate how many can be moved
+ // For under-loaded jurors, also check they haven't hit their personal maxAssignments
+ const underLoaded = jurorLoads.filter((j) => {
+ if (j._count >= input.targetPerJuror) return false
+ const userMax = jurorMaxMap.get(j.userId)
+ // If user has a personal max and is already at it, they can't receive more
+ if (userMax !== null && userMax !== undefined && j._count >= userMax) {
+ return false
+ }
+ return true
+ })
+
+ // Calculate how many can be moved, respecting per-juror limits
const excessTotal = overLoaded.reduce(
(sum, j) => sum + (j._count - input.targetPerJuror),
0
)
- const capacityTotal = underLoaded.reduce(
- (sum, j) => sum + (input.targetPerJuror - j._count),
- 0
- )
+ const capacityTotal = underLoaded.reduce((sum, j) => {
+ const userMax = jurorMaxMap.get(j.userId)
+ const effectiveTarget = (userMax !== null && userMax !== undefined)
+ ? Math.min(input.targetPerJuror, userMax)
+ : input.targetPerJuror
+ return sum + Math.max(0, effectiveTarget - j._count)
+ }, 0)
const movableCount = Math.min(excessTotal, capacityTotal)
if (input.dryRun) {
@@ -322,7 +341,12 @@ export const stageAssignmentRouter = router({
for (const assignment of assignmentsToMove) {
// Find an under-loaded juror who doesn't already have this project
for (const under of underLoaded) {
- if (under._count >= input.targetPerJuror) continue
+ // Respect both target and personal maxAssignments
+ const userMax = jurorMaxMap.get(under.userId)
+ const effectiveCapacity = (userMax !== null && userMax !== undefined)
+ ? Math.min(input.targetPerJuror, userMax)
+ : input.targetPerJuror
+ if (under._count >= effectiveCapacity) continue
// Check no existing assignment for this juror-project pair
const exists = await tx.assignment.findFirst({
diff --git a/src/server/services/ai-assignment.ts b/src/server/services/ai-assignment.ts
index fa8253a..5ad2b59 100644
--- a/src/server/services/ai-assignment.ts
+++ b/src/server/services/ai-assignment.ts
@@ -80,6 +80,7 @@ interface AssignmentConstraints {
requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number
+ jurorLimits?: Record // userId -> personal max assignments
existingAssignments: Array<{
jurorId: string
projectId: string
@@ -260,9 +261,24 @@ function buildBatchPrompt(
}))
.filter((a) => a.jurorId && a.projectId)
+ // Build per-juror limits mapped to anonymous IDs
+ let jurorLimitsStr = ''
+ if (constraints.jurorLimits && Object.keys(constraints.jurorLimits).length > 0) {
+ const anonymousLimits: Record = {}
+ for (const [realId, limit] of Object.entries(constraints.jurorLimits)) {
+ const anonId = jurorIdMap.get(realId)
+ if (anonId) {
+ anonymousLimits[anonId] = limit
+ }
+ }
+ if (Object.keys(anonymousLimits).length > 0) {
+ jurorLimitsStr = `\nJUROR_LIMITS: ${JSON.stringify(anonymousLimits)} (per-juror max assignments, override global max)`
+ }
+ }
+
return `JURORS: ${JSON.stringify(jurors)}
PROJECTS: ${JSON.stringify(projects)}
-CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror
+CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror${jurorLimitsStr}
EXISTING: ${JSON.stringify(anonymousExisting)}
Return JSON: {"assignments": [...]}`
}
diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts
index 660dcbe..2bb30f0 100644
--- a/src/server/services/smart-assignment.ts
+++ b/src/server/services/smart-assignment.ts
@@ -419,12 +419,20 @@ export async function getSmartSuggestions(options: {
const suggestions: AssignmentScore[] = []
for (const user of users) {
- // Skip users at AI max (they won't appear in suggestions)
const currentCount = user._count.assignments
+
+ // Skip users at AI max (they won't appear in suggestions)
if (currentCount >= aiMaxPerJudge) {
continue
}
+ // Per-juror hard block: skip entirely if at personal maxAssignments limit
+ if (user.maxAssignments !== null && user.maxAssignments !== undefined) {
+ if (currentCount >= user.maxAssignments) {
+ continue
+ }
+ }
+
for (const project of projects) {
// Skip if already assigned
const pairKey = `${user.id}:${project.id}`
@@ -621,6 +629,13 @@ export async function getMentorSuggestionsForProject(
continue
}
+ // Per-mentor hard block: skip entirely if at personal maxAssignments limit
+ if (mentor.maxAssignments !== null && mentor.maxAssignments !== undefined) {
+ if (mentor._count.mentorAssignments >= mentor.maxAssignments) {
+ continue
+ }
+ }
+
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
mentor.expertiseTags,
projectTags
diff --git a/src/types/pipeline-wizard.ts b/src/types/pipeline-wizard.ts
index dcce9a4..db4b557 100644
--- a/src/types/pipeline-wizard.ts
+++ b/src/types/pipeline-wizard.ts
@@ -22,6 +22,7 @@ export type FileRequirementConfig = {
export type FilterConfig = {
rules: FilterRuleConfig[]
aiRubricEnabled: boolean
+ aiCriteriaText: string
aiConfidenceThresholds: {
high: number
medium: number