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:
2026-02-13 23:45:21 +01:00
parent 451b483880
commit 70cfad7d46
28 changed files with 1312 additions and 200 deletions

View File

@@ -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,
}
}),
/**