diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index fcc4513..0c797cc 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -86,6 +86,8 @@ import { Eye, Pencil, Mail, + History, + ChevronRight, } from 'lucide-react' import { Command, @@ -1920,6 +1922,9 @@ export default function RoundDetailPage() { + {/* Reassignment History (collapsible) */} + + {/* Card 2: Assignments — with action buttons in header */} @@ -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 ( + + setExpanded(!expanded)} + > + + + Reassignment History + + + Juror dropout and COI reassignment audit trail + + {expanded && ( + + {isLoading ? ( +
+ {[1, 2].map((i) => )} +
+ ) : !events || events.length === 0 ? ( +

+ No reassignment events for this round +

+ ) : ( +
+ {events.map((event) => ( +
+
+
+ + {event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'} + + + {event.droppedJuror.name} + +
+ + {new Date(event.timestamp).toLocaleString()} + +
+ +

+ By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned + {event.failedCount > 0 && `, ${event.failedCount} failed`} +

+ + {event.moves.length > 0 && ( +
+ + + + + + + + + {event.moves.map((move, i) => ( + + + + + ))} + +
ProjectReassigned To
+ {move.projectTitle} + + {move.newJurorName} +
+
+ )} + + {event.failedProjects.length > 0 && ( +
+

Could not reassign:

+
    + {event.failedProjects.map((p, i) => ( +
  • {p}
  • + ))} +
+
+ )} +
+ ))} +
+ )} +
+ )} +
+ ) +} + // ── Score Distribution ─────────────────────────────────────────────────── function ScoreDistribution({ roundId }: { roundId: string }) { diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 891a3ec..5005849 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -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 const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3 @@ -1199,8 +1239,22 @@ export const assignmentRouter = router({ ? (config.categoryQuotas as Record | 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 | 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 | 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 | 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 + }), })