Fix reassignment scoping bug + add reassignment history
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

Bug fix: reassignDroppedJuror, reassignAfterCOI, and getSuggestions all
fell back to querying ALL JURY_MEMBER users globally when the round had
no juryGroupId. This caused projects to be assigned to jurors who are no
longer active in the jury pool. Now scopes to jury group members when
available, otherwise to jurors already assigned to the round.

Also adds getSuggestions jury group scoping (matching runAIAssignmentJob).

New feature: Reassignment History panel on admin round page (collapsible)
shows per-project detail of where dropped/COI-reassigned projects went.
Reconstructs retroactive data from audit log timestamps + MANUAL
assignments for pre-fix entries. Future entries log full move details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-20 14:18:49 +01:00
parent 0607d79484
commit 0d0571ebf2
2 changed files with 343 additions and 12 deletions

View File

@@ -72,10 +72,24 @@ export async function reassignAfterCOI(params: {
.filter((m) => m.user.status === 'ACTIVE')
.map((m) => m.user)
} else {
candidateJurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: { id: true, name: true, email: true, maxAssignments: true },
// No jury group — scope to jurors already assigned to this round
const roundJurorIds = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds.map((a) => a.userId)
candidateJurors = activeRoundJurorIds.length > 0
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
// Filter out already assigned and COI jurors
@@ -262,14 +276,28 @@ async function reassignDroppedJurorAssignments(params: {
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
.map((m) => m.user)
} else {
candidateJurors = await prisma.user.findMany({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
id: { not: params.droppedJurorId },
},
select: { id: true, name: true, email: true, maxAssignments: true },
// No jury group configured — scope to jurors already assigned to this round
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
// accounts that aren't part of this round's jury.
const roundJurorIds = await prisma.assignment.findMany({
where: { roundId: params.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== params.droppedJurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
@@ -447,6 +475,17 @@ async function reassignDroppedJurorAssignments(params: {
}
if (params.auditUserId) {
// Build per-project move detail for audit trail
const moveDetails = actualMoves.map((move) => {
const juror = candidateMeta.get(move.newJurorId)
return {
projectId: move.projectId,
projectTitle: move.projectTitle,
newJurorId: move.newJurorId,
newJurorName: juror?.name || juror?.email || move.newJurorId,
}
})
await logAudit({
prisma,
userId: params.auditUserId,
@@ -462,6 +501,7 @@ async function reassignDroppedJurorAssignments(params: {
skippedProjects,
reassignedTo,
removedFromGroup,
moves: moveDetails,
},
ipAddress: params.auditIp,
userAgent: params.auditUserAgent,
@@ -1180,7 +1220,7 @@ export const assignmentRouter = router({
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
select: { configJson: true, juryGroupId: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
@@ -1199,8 +1239,22 @@ export const assignmentRouter = router({
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
: undefined
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds: string[] | undefined
if (stage.juryGroupId) {
const groupMembers = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: stage.juryGroupId },
select: { userId: true },
})
scopedJurorIds = groupMembers.map((m) => m.userId)
}
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
id: true,
name: true,
@@ -2012,4 +2066,176 @@ export const assignmentRouter = router({
auditUserAgent: ctx.userAgent,
})
}),
/**
* Get reshuffle history for a round — shows all dropout/COI reassignment events
* with per-project detail of where each project was moved to.
*/
getReassignmentHistory: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get all reshuffle + COI audit entries for this round
const auditEntries = await ctx.prisma.auditLog.findMany({
where: {
entityType: { in: ['Round', 'Assignment'] },
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
entityId: input.roundId,
},
orderBy: { timestamp: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
})
// Also get COI reassignment entries that reference this round in detailsJson
const coiEntries = await ctx.prisma.auditLog.findMany({
where: {
action: 'COI_REASSIGNMENT',
entityType: 'Assignment',
},
orderBy: { timestamp: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
})
// Filter COI entries to this round
const coiForRound = coiEntries.filter((e) => {
const details = e.detailsJson as Record<string, unknown> | null
return details?.roundId === input.roundId
})
// For retroactive data: find all MANUAL assignments created in this round
// that were created by an admin (not the juror themselves)
const manualAssignments = await ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
method: 'MANUAL',
createdBy: { not: null },
},
include: {
user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
})
type ReshuffleEvent = {
id: string
type: 'DROPOUT' | 'COI'
timestamp: Date
performedBy: { name: string | null; email: string }
droppedJuror: { id: string; name: string }
movedCount: number
failedCount: number
failedProjects: string[]
moves: { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
}
const events: ReshuffleEvent[] = []
for (const entry of auditEntries) {
const details = entry.detailsJson as Record<string, unknown> | null
if (!details) continue
if (entry.action === 'JUROR_DROPOUT_RESHUFFLE') {
// Check if this entry already has per-move detail (new format)
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
// If no moves in audit (old format), reconstruct from assignments
let reconstructedMoves = moves
if (moves.length === 0 && (details.movedCount as number) > 0) {
// Find MANUAL assignments created around the same time (within 5 seconds)
const eventTime = entry.timestamp.getTime()
reconstructedMoves = manualAssignments
.filter((a) => {
const diff = Math.abs(a.createdAt.getTime() - eventTime)
return diff < 5000 && a.createdBy === entry.userId
})
.map((a) => ({
projectId: a.project.id,
projectTitle: a.project.title,
newJurorId: a.user.id,
newJurorName: a.user.name || a.user.email,
}))
}
events.push({
id: entry.id,
type: 'DROPOUT',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: details.droppedJurorId as string,
name: (details.droppedJurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves: reconstructedMoves,
})
}
}
// Process COI entries
for (const entry of coiForRound) {
const details = entry.detailsJson as Record<string, unknown> | null
if (!details) continue
// Look up project title
const project = details.projectId
? await ctx.prisma.project.findUnique({
where: { id: details.projectId as string },
select: { title: true },
})
: null
// Look up new juror name
const newJuror = details.newJurorId
? await ctx.prisma.user.findUnique({
where: { id: details.newJurorId as string },
select: { name: true, email: true },
})
: null
// Look up old juror name
const oldJuror = details.oldJurorId
? await ctx.prisma.user.findUnique({
where: { id: details.oldJurorId as string },
select: { name: true, email: true },
})
: null
events.push({
id: entry.id,
type: 'COI',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.oldJurorId as string) || '',
name: oldJuror?.name || oldJuror?.email || 'Unknown',
},
movedCount: 1,
failedCount: 0,
failedProjects: [],
moves: [{
projectId: (details.projectId as string) || '',
projectTitle: project?.title || 'Unknown',
newJurorId: (details.newJurorId as string) || '',
newJurorName: newJuror?.name || newJuror?.email || 'Unknown',
}],
})
}
// Sort all events by timestamp descending
events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
return events
}),
})