Fix reassignment scoping bug + add reassignment history
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
@@ -86,6 +86,8 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Pencil,
|
Pencil,
|
||||||
Mail,
|
Mail,
|
||||||
|
History,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -1920,6 +1922,9 @@ export default function RoundDetailPage() {
|
|||||||
<ScoreDistribution roundId={roundId} />
|
<ScoreDistribution roundId={roundId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reassignment History (collapsible) */}
|
||||||
|
<ReassignmentHistory roundId={roundId} />
|
||||||
|
|
||||||
{/* Card 2: Assignments — with action buttons in header */}
|
{/* Card 2: Assignments — with action buttons in header */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -2487,6 +2492,106 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reassignment History ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReassignmentHistory({ roundId }: { roundId: string }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: expanded },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
Reassignment History
|
||||||
|
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Juror dropout and COI reassignment audit trail</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{expanded && (
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : !events || events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No reassignment events for this round
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.id} className="border rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||||
|
{event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{event.droppedJuror.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(event.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
|
||||||
|
{event.failedCount > 0 && `, ${event.failedCount} failed`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{event.moves.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Project</th>
|
||||||
|
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{event.moves.map((move, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-2 max-w-[250px] truncate">
|
||||||
|
{move.projectTitle}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 font-medium">
|
||||||
|
{move.newJurorName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.failedProjects.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<p className="text-xs font-medium text-destructive">Could not reassign:</p>
|
||||||
|
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||||
|
{event.failedProjects.map((p, i) => (
|
||||||
|
<li key={i}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Score Distribution ───────────────────────────────────────────────────
|
// ── Score Distribution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function ScoreDistribution({ roundId }: { roundId: string }) {
|
function ScoreDistribution({ roundId }: { roundId: string }) {
|
||||||
|
|||||||
@@ -72,10 +72,24 @@ export async function reassignAfterCOI(params: {
|
|||||||
.filter((m) => m.user.status === 'ACTIVE')
|
.filter((m) => m.user.status === 'ACTIVE')
|
||||||
.map((m) => m.user)
|
.map((m) => m.user)
|
||||||
} else {
|
} else {
|
||||||
candidateJurors = await prisma.user.findMany({
|
// No jury group — scope to jurors already assigned to this round
|
||||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
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
|
// 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)
|
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
|
||||||
.map((m) => m.user)
|
.map((m) => m.user)
|
||||||
} else {
|
} else {
|
||||||
candidateJurors = await prisma.user.findMany({
|
// No jury group configured — scope to jurors already assigned to this round
|
||||||
where: {
|
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
|
||||||
role: 'JURY_MEMBER',
|
// accounts that aren't part of this round's jury.
|
||||||
status: 'ACTIVE',
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
id: { not: params.droppedJurorId },
|
where: { roundId: params.roundId },
|
||||||
},
|
select: { userId: true },
|
||||||
select: { id: true, name: true, email: true, maxAssignments: 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) {
|
if (candidateJurors.length === 0) {
|
||||||
@@ -447,6 +475,17 @@ async function reassignDroppedJurorAssignments(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.auditUserId) {
|
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({
|
await logAudit({
|
||||||
prisma,
|
prisma,
|
||||||
userId: params.auditUserId,
|
userId: params.auditUserId,
|
||||||
@@ -462,6 +501,7 @@ async function reassignDroppedJurorAssignments(params: {
|
|||||||
skippedProjects,
|
skippedProjects,
|
||||||
reassignedTo,
|
reassignedTo,
|
||||||
removedFromGroup,
|
removedFromGroup,
|
||||||
|
moves: moveDetails,
|
||||||
},
|
},
|
||||||
ipAddress: params.auditIp,
|
ipAddress: params.auditIp,
|
||||||
userAgent: params.auditUserAgent,
|
userAgent: params.auditUserAgent,
|
||||||
@@ -1180,7 +1220,7 @@ export const assignmentRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { configJson: true },
|
select: { configJson: true, juryGroupId: true },
|
||||||
})
|
})
|
||||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
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)
|
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
|
||||||
: 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({
|
const jurors = await ctx.prisma.user.findMany({
|
||||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
where: {
|
||||||
|
role: 'JURY_MEMBER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -2012,4 +2066,176 @@ export const assignmentRouter = router({
|
|||||||
auditUserAgent: ctx.userAgent,
|
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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user