Add jury assignment transfer, cap redistribution, and learning hub overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
- Add getTransferCandidates/transferAssignments procedures for targeted assignment moves between jurors with TOCTOU guards and audit logging - Add getOverCapPreview/redistributeOverCap for auto-redistributing assignments when a juror's cap is lowered below their current load - Add TransferAssignmentsDialog (2-step: select projects, pick destinations) - Extend InlineMemberCap with over-cap detection and redistribute banner - Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events - Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB, add coverImageKey, resource detail pages for jury/mentor, shared renderer - Migration: 20260221200000_learning_hub_overhaul Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2069,6 +2069,762 @@ export const assignmentRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get transfer candidates: which of the source juror's assignments can be moved,
|
||||
* and which other jurors are eligible to receive them.
|
||||
*/
|
||||
getTransferCandidates: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
sourceJurorId: z.string(),
|
||||
assignmentIds: z.array(z.string()),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Fetch requested assignments — must belong to source juror
|
||||
const requestedAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
id: { in: input.assignmentIds },
|
||||
roundId: input.roundId,
|
||||
userId: input.sourceJurorId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: { select: { title: true } },
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Filter to movable only
|
||||
const assignments = requestedAssignments.map((a) => ({
|
||||
id: a.id,
|
||||
projectId: a.projectId,
|
||||
projectTitle: a.project.title,
|
||||
evalStatus: a.evaluation?.status ?? null,
|
||||
movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
|
||||
}))
|
||||
|
||||
const movableProjectIds = assignments
|
||||
.filter((a) => a.movable)
|
||||
.map((a) => a.projectId)
|
||||
|
||||
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||
},
|
||||
})
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.sourceJurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
const activeRoundJurorIds = roundJurorIds
|
||||
.map((a) => a.userId)
|
||||
.filter((id) => id !== input.sourceJurorId)
|
||||
|
||||
candidateJurors = activeRoundJurorIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
// Existing assignments, loads, COI pairs
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
|
||||
// Completed evaluations count per candidate
|
||||
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { assignment: { select: { userId: true } } },
|
||||
})
|
||||
const completedCounts = new Map<string, number>()
|
||||
for (const e of completedEvals) {
|
||||
const uid = e.assignment.userId
|
||||
completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
// Build candidate list with eligibility per project
|
||||
const candidates = candidateJurors.map((j) => {
|
||||
const load = currentLoads.get(j.id) ?? 0
|
||||
const cap = j.maxAssignments ?? fallbackCap
|
||||
const completed = completedCounts.get(j.id) ?? 0
|
||||
const allCompleted = load > 0 && completed === load
|
||||
|
||||
const eligibleProjectIds = movableProjectIds.filter((pid) =>
|
||||
!alreadyAssigned.has(`${j.id}:${pid}`) &&
|
||||
!coiPairs.has(`${j.id}:${pid}`) &&
|
||||
load < cap
|
||||
)
|
||||
|
||||
return {
|
||||
userId: j.id,
|
||||
name: j.name || j.email,
|
||||
email: j.email,
|
||||
currentLoad: load,
|
||||
cap,
|
||||
allCompleted,
|
||||
eligibleProjectIds,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort: not-all-done first, then by lowest load
|
||||
candidates.sort((a, b) => {
|
||||
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||
return a.currentLoad - b.currentLoad
|
||||
})
|
||||
|
||||
return { assignments, candidates }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Transfer specific assignments from one juror to destination jurors.
|
||||
*/
|
||||
transferAssignments: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
sourceJurorId: z.string(),
|
||||
transfers: z.array(z.object({
|
||||
assignmentId: z.string(),
|
||||
destinationJurorId: z.string(),
|
||||
})),
|
||||
forceOverCap: z.boolean().default(false),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Verify all assignments belong to source juror and are movable
|
||||
const assignmentIds = input.transfers.map((t) => t.assignmentId)
|
||||
const sourceAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
id: { in: assignmentIds },
|
||||
roundId: input.roundId,
|
||||
userId: input.sourceJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
juryGroupId: true,
|
||||
isRequired: true,
|
||||
project: { select: { title: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))
|
||||
|
||||
// Build candidate pool data
|
||||
const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
|
||||
const destinationUsers = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: destinationIds } },
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
})
|
||||
const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: destinationIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
// Validate each transfer
|
||||
type PlannedMove = {
|
||||
assignmentId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
destinationJurorId: string
|
||||
juryGroupId: string | null
|
||||
isRequired: boolean
|
||||
}
|
||||
const plannedMoves: PlannedMove[] = []
|
||||
const failed: { assignmentId: string; reason: string }[] = []
|
||||
|
||||
for (const transfer of input.transfers) {
|
||||
const assignment = sourceMap.get(transfer.assignmentId)
|
||||
if (!assignment) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: 'Assignment not found or not movable' })
|
||||
continue
|
||||
}
|
||||
|
||||
const destUser = destUserMap.get(transfer.destinationJurorId)
|
||||
if (!destUser) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: 'Destination juror not found' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (alreadyAssigned.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is already assigned to this project` })
|
||||
continue
|
||||
}
|
||||
|
||||
if (coiPairs.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} has a COI with this project` })
|
||||
continue
|
||||
}
|
||||
|
||||
const destCap = destUser.maxAssignments ?? fallbackCap
|
||||
const destLoad = currentLoads.get(transfer.destinationJurorId) ?? 0
|
||||
if (destLoad >= destCap && !input.forceOverCap) {
|
||||
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is at cap (${destLoad}/${destCap})` })
|
||||
continue
|
||||
}
|
||||
|
||||
plannedMoves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
destinationJurorId: transfer.destinationJurorId,
|
||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
||||
isRequired: assignment.isRequired,
|
||||
})
|
||||
|
||||
// Track updated load for subsequent transfers to same destination
|
||||
alreadyAssigned.add(`${transfer.destinationJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(transfer.destinationJurorId, destLoad + 1)
|
||||
}
|
||||
|
||||
// Execute in transaction with TOCTOU guard
|
||||
const actualMoves: (PlannedMove & { newAssignmentId: string })[] = []
|
||||
|
||||
if (plannedMoves.length > 0) {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const move of plannedMoves) {
|
||||
const deleted = await tx.assignment.deleteMany({
|
||||
where: {
|
||||
id: move.assignmentId,
|
||||
userId: input.sourceJurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (deleted.count === 0) {
|
||||
failed.push({ assignmentId: move.assignmentId, reason: 'Assignment was modified concurrently' })
|
||||
continue
|
||||
}
|
||||
|
||||
const created = await tx.assignment.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.destinationJurorId,
|
||||
juryGroupId: move.juryGroupId ?? undefined,
|
||||
isRequired: move.isRequired,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
actualMoves.push({ ...move, newAssignmentId: created.id })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Notify destination jurors
|
||||
if (actualMoves.length > 0) {
|
||||
const destCounts: Record<string, number> = {}
|
||||
for (const move of actualMoves) {
|
||||
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(destCounts),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments via transfer in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'assignment_transfer' },
|
||||
})
|
||||
|
||||
// Notify admins
|
||||
const sourceJuror = await ctx.prisma.user.findUnique({
|
||||
where: { id: input.sourceJurorId },
|
||||
select: { name: true, email: true },
|
||||
})
|
||||
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
|
||||
|
||||
const topReceivers = Object.entries(destCounts)
|
||||
.map(([jurorId, count]) => {
|
||||
const u = destUserMap.get(jurorId)
|
||||
return `${u?.name || u?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||
title: 'Assignment Transfer',
|
||||
message: `Transferred ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failed.length > 0 ? ` ${failed.length} transfer(s) failed.` : ''}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
linkLabel: 'View Round',
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
sourceJurorId: input.sourceJurorId,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failed.length,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ASSIGNMENT_TRANSFER',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
sourceJurorId: input.sourceJurorId,
|
||||
sourceJurorName: sourceName,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failed.length,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.destinationJurorId,
|
||||
newJurorName: destUserMap.get(m.destinationJurorId)?.name || destUserMap.get(m.destinationJurorId)?.email || m.destinationJurorId,
|
||||
})),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
succeeded: actualMoves.map((m) => ({
|
||||
assignmentId: m.assignmentId,
|
||||
projectId: m.projectId,
|
||||
destinationJurorId: m.destinationJurorId,
|
||||
})),
|
||||
failed,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview the impact of lowering a juror's cap below their current load.
|
||||
*/
|
||||
getOverCapPreview: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
jurorId: z.string(),
|
||||
newCap: z.number().int().min(1),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const total = await ctx.prisma.assignment.count({
|
||||
where: { roundId: input.roundId, userId: input.jurorId },
|
||||
})
|
||||
|
||||
const immovableCount = await ctx.prisma.assignment.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
userId: input.jurorId,
|
||||
evaluation: { status: { notIn: [...MOVABLE_EVAL_STATUSES] } },
|
||||
},
|
||||
})
|
||||
|
||||
const movableCount = total - immovableCount
|
||||
const overCapCount = Math.max(0, total - input.newCap)
|
||||
|
||||
return {
|
||||
total,
|
||||
overCapCount,
|
||||
movableOverCap: Math.min(overCapCount, movableCount),
|
||||
immovableOverCap: Math.max(0, overCapCount - movableCount),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Redistribute over-cap assignments after lowering a juror's cap.
|
||||
* Moves the newest/least-progressed movable assignments to other eligible jurors.
|
||||
*/
|
||||
redistributeOverCap: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
jurorId: z.string(),
|
||||
newCap: z.number().int().min(1),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const fallbackCap =
|
||||
(config.maxLoadPerJuror as number) ??
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
|
||||
const jurorAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId, userId: input.jurorId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
juryGroupId: true,
|
||||
isRequired: true,
|
||||
createdAt: true,
|
||||
project: { select: { title: true } },
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
|
||||
if (overCapCount === 0) {
|
||||
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
|
||||
}
|
||||
|
||||
// Separate movable and immovable, pick the newest movable ones for redistribution
|
||||
const movable = jurorAssignments.filter(
|
||||
(a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
|
||||
)
|
||||
|
||||
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
|
||||
movable.sort((a, b) => {
|
||||
const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
|
||||
const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
|
||||
if (diff !== 0) return diff
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
|
||||
const assignmentsToMove = movable.slice(0, overCapCount)
|
||||
|
||||
if (assignmentsToMove.length === 0) {
|
||||
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
|
||||
}
|
||||
|
||||
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
|
||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||
|
||||
if (round.juryGroupId) {
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||
},
|
||||
})
|
||||
candidateJurors = members
|
||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId)
|
||||
.map((m) => m.user)
|
||||
} else {
|
||||
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
const activeRoundJurorIds = roundJurorIds
|
||||
.map((a) => a.userId)
|
||||
.filter((id) => id !== input.jurorId)
|
||||
|
||||
candidateJurors = activeRoundJurorIds.length > 0
|
||||
? await ctx.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) {
|
||||
return {
|
||||
redistributed: 0,
|
||||
failed: assignmentsToMove.length,
|
||||
failedProjects: assignmentsToMove.map((a) => a.project.title),
|
||||
moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = candidateJurors.map((j) => j.id)
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||
const currentLoads = new Map<string, number>()
|
||||
for (const a of existingAssignments) {
|
||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
hasConflict: true,
|
||||
userId: { in: candidateIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||
|
||||
const caps = new Map<string, number>()
|
||||
for (const juror of candidateJurors) {
|
||||
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
||||
}
|
||||
|
||||
// Check which candidates have completed all their evaluations
|
||||
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { assignment: { select: { userId: true } } },
|
||||
})
|
||||
const completedCounts = new Map<string, number>()
|
||||
for (const e of completedEvals) {
|
||||
completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||
|
||||
type PlannedMove = {
|
||||
assignmentId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
newJurorId: string
|
||||
juryGroupId: string | null
|
||||
isRequired: boolean
|
||||
}
|
||||
const plannedMoves: PlannedMove[] = []
|
||||
const failedProjects: string[] = []
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
const eligible = candidateIds
|
||||
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
||||
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
||||
.sort((a, b) => {
|
||||
// Prefer jurors who haven't completed all their work
|
||||
const aLoad = currentLoads.get(a) ?? 0
|
||||
const bLoad = currentLoads.get(b) ?? 0
|
||||
const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
|
||||
const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
|
||||
if (aComplete !== bComplete) return aComplete ? 1 : -1
|
||||
const loadDiff = aLoad - bLoad
|
||||
if (loadDiff !== 0) return loadDiff
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
if (eligible.length === 0) {
|
||||
failedProjects.push(assignment.project.title)
|
||||
continue
|
||||
}
|
||||
|
||||
const selectedJurorId = eligible[0]
|
||||
plannedMoves.push({
|
||||
assignmentId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
newJurorId: selectedJurorId,
|
||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
||||
isRequired: assignment.isRequired,
|
||||
})
|
||||
|
||||
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
||||
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Execute in transaction with TOCTOU guard
|
||||
const actualMoves: PlannedMove[] = []
|
||||
|
||||
if (plannedMoves.length > 0) {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const move of plannedMoves) {
|
||||
const deleted = await tx.assignment.deleteMany({
|
||||
where: {
|
||||
id: move.assignmentId,
|
||||
userId: input.jurorId,
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (deleted.count === 0) {
|
||||
failedProjects.push(move.projectTitle)
|
||||
continue
|
||||
}
|
||||
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
projectId: move.projectId,
|
||||
userId: move.newJurorId,
|
||||
juryGroupId: move.juryGroupId ?? undefined,
|
||||
isRequired: move.isRequired,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
actualMoves.push(move)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Notify destination jurors
|
||||
if (actualMoves.length > 0) {
|
||||
const destCounts: Record<string, number> = {}
|
||||
for (const move of actualMoves) {
|
||||
destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
|
||||
}
|
||||
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(destCounts),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'cap_redistribute' },
|
||||
})
|
||||
|
||||
const juror = await ctx.prisma.user.findUnique({
|
||||
where: { id: input.jurorId },
|
||||
select: { name: true, email: true },
|
||||
})
|
||||
const jurorName = juror?.name || juror?.email || 'Unknown'
|
||||
|
||||
const topReceivers = Object.entries(destCounts)
|
||||
.map(([jurorId, count]) => {
|
||||
const u = candidateMeta.get(jurorId)
|
||||
return `${u?.name || u?.email || jurorId} (${count})`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||
title: 'Cap Redistribution',
|
||||
message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
|
||||
linkUrl: `/admin/rounds/${round.id}`,
|
||||
linkLabel: 'View Round',
|
||||
metadata: {
|
||||
roundId: round.id,
|
||||
jurorId: input.jurorId,
|
||||
newCap: input.newCap,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failedProjects.length,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CAP_REDISTRIBUTE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: {
|
||||
jurorId: input.jurorId,
|
||||
jurorName,
|
||||
newCap: input.newCap,
|
||||
movedCount: actualMoves.length,
|
||||
failedCount: failedProjects.length,
|
||||
failedProjects,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.newJurorId,
|
||||
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
|
||||
})),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
redistributed: actualMoves.length,
|
||||
failed: failedProjects.length,
|
||||
failedProjects,
|
||||
moves: actualMoves.map((m) => ({
|
||||
projectId: m.projectId,
|
||||
projectTitle: m.projectTitle,
|
||||
newJurorId: m.newJurorId,
|
||||
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get reshuffle history for a round — shows all dropout/COI reassignment events
|
||||
* with per-project detail of where each project was moved to.
|
||||
@@ -2080,7 +2836,7 @@ export const assignmentRouter = router({
|
||||
const auditEntries = await ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: { in: ['Round', 'Assignment'] },
|
||||
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
|
||||
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
|
||||
entityId: input.roundId,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
@@ -2124,7 +2880,7 @@ export const assignmentRouter = router({
|
||||
|
||||
type ReshuffleEvent = {
|
||||
id: string
|
||||
type: 'DROPOUT' | 'COI'
|
||||
type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
|
||||
timestamp: Date
|
||||
performedBy: { name: string | null; email: string }
|
||||
droppedJuror: { id: string; name: string }
|
||||
@@ -2179,6 +2935,44 @@ export const assignmentRouter = router({
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves: reconstructedMoves,
|
||||
})
|
||||
} else if (entry.action === 'ASSIGNMENT_TRANSFER') {
|
||||
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
|
||||
events.push({
|
||||
id: entry.id,
|
||||
type: 'TRANSFER',
|
||||
timestamp: entry.timestamp,
|
||||
performedBy: {
|
||||
name: entry.user?.name ?? null,
|
||||
email: entry.user?.email ?? '',
|
||||
},
|
||||
droppedJuror: {
|
||||
id: (details.sourceJurorId as string) || '',
|
||||
name: (details.sourceJurorName as string) || 'Unknown',
|
||||
},
|
||||
movedCount: (details.movedCount as number) || 0,
|
||||
failedCount: (details.failedCount as number) || 0,
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves,
|
||||
})
|
||||
} else if (entry.action === 'CAP_REDISTRIBUTE') {
|
||||
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
|
||||
events.push({
|
||||
id: entry.id,
|
||||
type: 'CAP_REDISTRIBUTE',
|
||||
timestamp: entry.timestamp,
|
||||
performedBy: {
|
||||
name: entry.user?.name ?? null,
|
||||
email: entry.user?.email ?? '',
|
||||
},
|
||||
droppedJuror: {
|
||||
id: (details.jurorId as string) || '',
|
||||
name: (details.jurorName as string) || 'Unknown',
|
||||
},
|
||||
movedCount: (details.movedCount as number) || 0,
|
||||
failedCount: (details.failedCount as number) || 0,
|
||||
failedProjects: (details.failedProjects as string[]) || [],
|
||||
moves,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user