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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user