Platform polish: bulk invite, file requirements, filtering redesign, UX fixes
- F1: Set seed jury/mentors/observers to NONE status (not invited), remove passwords - F2: Add bulk invite UI with checkbox selection and floating toolbar - F3: Add getProjectRequirements backend query + requirement slots on project detail - F4: Redesign filtering section: AI criteria textarea, "What AI sees" card, field-aware eligibility rules with human-readable previews - F5: Auto-redirect to pipeline detail when only one pipeline exists - F6: Make project names clickable in pipeline intake panel - F7: Fix pipeline creation error: edition context fallback + .min(1) validation - Pipeline wizard sections: add isActive locking, info tooltips, UX improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>()
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
|
||||
|
||||
@@ -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<string, number> = {}
|
||||
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<string, unknown>
|
||||
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<string, number>()
|
||||
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<string, number>
|
||||
)
|
||||
|
||||
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<string, unknown>
|
||||
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||
|
||||
const runningCounts = new Map<string, number>()
|
||||
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<string, unknown>
|
||||
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
|
||||
|
||||
const runningCounts = new Map<string, number>()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> | 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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -80,6 +80,7 @@ interface AssignmentConstraints {
|
||||
requiredReviewsPerProject: number
|
||||
minAssignmentsPerJuror?: number
|
||||
maxAssignmentsPerJuror?: number
|
||||
jurorLimits?: Record<string, number> // 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<string, number> = {}
|
||||
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": [...]}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user