579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { prisma } from '@/lib/prisma'
|
||
|
|
import {
|
||
|
|
createNotification,
|
||
|
|
notifyAdmins,
|
||
|
|
NotificationTypes,
|
||
|
|
} from './in-app-notification'
|
||
|
|
import { logAudit } from '@/server/utils/audit'
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reassign a project after a juror declares COI.
|
||
|
|
* Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment.
|
||
|
|
* Returns the new juror info or null if no eligible juror found.
|
||
|
|
*/
|
||
|
|
export async function reassignAfterCOI(params: {
|
||
|
|
assignmentId: string
|
||
|
|
auditUserId?: string
|
||
|
|
auditIp?: string
|
||
|
|
auditUserAgent?: string
|
||
|
|
}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> {
|
||
|
|
const assignment = await prisma.assignment.findUnique({
|
||
|
|
where: { id: params.assignmentId },
|
||
|
|
include: {
|
||
|
|
round: { select: { id: true, name: true, configJson: true, juryGroupId: true } },
|
||
|
|
project: { select: { id: true, title: true } },
|
||
|
|
user: { select: { id: true, name: true, email: true } },
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!assignment) return null
|
||
|
|
|
||
|
|
const { roundId, projectId } = assignment
|
||
|
|
const config = (assignment.round.configJson ?? {}) as Record<string, unknown>
|
||
|
|
const maxAssignmentsPerJuror =
|
||
|
|
(config.maxLoadPerJuror as number) ??
|
||
|
|
(config.maxAssignmentsPerJuror as number) ??
|
||
|
|
20
|
||
|
|
|
||
|
|
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
||
|
|
|
||
|
|
// 1. Currently assigned to this project in ANY round (not just current)
|
||
|
|
const allProjectAssignments = await prisma.assignment.findMany({
|
||
|
|
where: { projectId },
|
||
|
|
select: { userId: true },
|
||
|
|
})
|
||
|
|
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
||
|
|
|
||
|
|
// 2. COI records for this project (any juror who declared conflict, ever)
|
||
|
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||
|
|
where: { projectId, hasConflict: true },
|
||
|
|
select: { userId: true },
|
||
|
|
})
|
||
|
|
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
||
|
|
|
||
|
|
// 3. Historical: jurors who previously had this project but were removed
|
||
|
|
// (via COI reassignment or admin transfer — tracked in audit logs)
|
||
|
|
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
||
|
|
where: {
|
||
|
|
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
||
|
|
detailsJson: { path: ['projectId'], equals: projectId },
|
||
|
|
},
|
||
|
|
select: { detailsJson: true },
|
||
|
|
})
|
||
|
|
for (const log of historicalAuditLogs) {
|
||
|
|
const details = log.detailsJson as Record<string, unknown> | null
|
||
|
|
if (!details) continue
|
||
|
|
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
||
|
|
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
||
|
|
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
||
|
|
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
||
|
|
// Transfer logs may have a moves array with per-project details
|
||
|
|
if (Array.isArray(details.moves)) {
|
||
|
|
for (const move of details.moves as Array<Record<string, unknown>>) {
|
||
|
|
if (move.projectId === projectId && move.newJurorId) {
|
||
|
|
// The juror who received via past transfer also had it
|
||
|
|
excludedUserIds.add(move.newJurorId as string)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Find candidate jurors ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||
|
|
|
||
|
|
if (assignment.round.juryGroupId) {
|
||
|
|
const members = await prisma.juryGroupMember.findMany({
|
||
|
|
where: { juryGroupId: assignment.round.juryGroupId },
|
||
|
|
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
|
||
|
|
})
|
||
|
|
candidateJurors = members
|
||
|
|
.filter((m) => m.user.status === 'ACTIVE')
|
||
|
|
.map((m) => m.user)
|
||
|
|
} else {
|
||
|
|
// 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 },
|
||
|
|
roles: { has: 'JURY_MEMBER' },
|
||
|
|
status: 'ACTIVE',
|
||
|
|
},
|
||
|
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||
|
|
})
|
||
|
|
: []
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter out all excluded jurors (current assignments, COI, historical)
|
||
|
|
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
|
||
|
|
|
||
|
|
if (eligible.length === 0) return null
|
||
|
|
|
||
|
|
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
||
|
|
|
||
|
|
const eligibleIds = eligible.map((j) => j.id)
|
||
|
|
|
||
|
|
// Get assignment counts and evaluation completion for eligible jurors in this round
|
||
|
|
const roundAssignments = await prisma.assignment.findMany({
|
||
|
|
where: { roundId, userId: { in: eligibleIds } },
|
||
|
|
select: { userId: true, evaluation: { select: { status: true } } },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Build per-juror stats: total assignments, completed evaluations
|
||
|
|
const jurorStats = new Map<string, { total: number; completed: number }>()
|
||
|
|
for (const a of roundAssignments) {
|
||
|
|
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
||
|
|
stats.total++
|
||
|
|
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
||
|
|
stats.completed++
|
||
|
|
}
|
||
|
|
jurorStats.set(a.userId, stats)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rank jurors: under cap, then prefer those still working (completed < total)
|
||
|
|
const ranked = eligible
|
||
|
|
.map((j) => {
|
||
|
|
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
||
|
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||
|
|
const hasIncomplete = stats.completed < stats.total
|
||
|
|
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
||
|
|
})
|
||
|
|
.filter((j) => j.currentCount < j.effectiveMax)
|
||
|
|
.sort((a, b) => {
|
||
|
|
// 1. Prefer jurors with incomplete evaluations (still active)
|
||
|
|
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
||
|
|
// 2. Then fewest current assignments (load balancing)
|
||
|
|
return a.currentCount - b.currentCount
|
||
|
|
})
|
||
|
|
|
||
|
|
if (ranked.length === 0) return null
|
||
|
|
|
||
|
|
const replacement = ranked[0]
|
||
|
|
|
||
|
|
// Delete old assignment and create replacement atomically.
|
||
|
|
// Cascade deletes COI record and any draft evaluation.
|
||
|
|
const newAssignment = await prisma.$transaction(async (tx) => {
|
||
|
|
await tx.assignment.delete({ where: { id: params.assignmentId } })
|
||
|
|
return tx.assignment.create({
|
||
|
|
data: {
|
||
|
|
userId: replacement.id,
|
||
|
|
projectId,
|
||
|
|
roundId,
|
||
|
|
juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
|
||
|
|
isRequired: assignment.isRequired,
|
||
|
|
method: 'MANUAL',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// Notify the replacement juror (COI-specific notification)
|
||
|
|
await createNotification({
|
||
|
|
userId: replacement.id,
|
||
|
|
type: NotificationTypes.COI_REASSIGNED,
|
||
|
|
title: 'Project Reassigned to You (COI)',
|
||
|
|
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
||
|
|
linkUrl: `/jury/competitions`,
|
||
|
|
linkLabel: 'View Assignment',
|
||
|
|
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Notify admins of the reassignment
|
||
|
|
await notifyAdmins({
|
||
|
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||
|
|
title: 'COI Auto-Reassignment',
|
||
|
|
message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`,
|
||
|
|
linkUrl: `/admin/rounds/${roundId}`,
|
||
|
|
linkLabel: 'View Round',
|
||
|
|
metadata: {
|
||
|
|
projectId,
|
||
|
|
oldJurorId: assignment.userId,
|
||
|
|
newJurorId: replacement.id,
|
||
|
|
reason: 'COI',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit
|
||
|
|
if (params.auditUserId) {
|
||
|
|
await logAudit({
|
||
|
|
prisma,
|
||
|
|
userId: params.auditUserId,
|
||
|
|
action: 'COI_REASSIGNMENT',
|
||
|
|
entityType: 'Assignment',
|
||
|
|
entityId: newAssignment.id,
|
||
|
|
detailsJson: {
|
||
|
|
oldAssignmentId: params.assignmentId,
|
||
|
|
oldJurorId: assignment.userId,
|
||
|
|
newJurorId: replacement.id,
|
||
|
|
projectId,
|
||
|
|
roundId,
|
||
|
|
},
|
||
|
|
ipAddress: params.auditIp,
|
||
|
|
userAgent: params.auditUserAgent,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
newJurorId: replacement.id,
|
||
|
|
newJurorName: replacement.name || replacement.email,
|
||
|
|
newAssignmentId: newAssignment.id,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||
|
|
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
||
|
|
|
||
|
|
export async function reassignDroppedJurorAssignments(params: {
|
||
|
|
roundId: string
|
||
|
|
droppedJurorId: string
|
||
|
|
auditUserId?: string
|
||
|
|
auditIp?: string
|
||
|
|
auditUserAgent?: string
|
||
|
|
}) {
|
||
|
|
const round = await prisma.round.findUnique({
|
||
|
|
where: { id: params.roundId },
|
||
|
|
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!round) {
|
||
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const droppedJuror = await prisma.user.findUnique({
|
||
|
|
where: { id: params.droppedJurorId },
|
||
|
|
select: { id: true, name: true, email: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!droppedJuror) {
|
||
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||
|
|
const fallbackCap =
|
||
|
|
(config.maxLoadPerJuror as number) ??
|
||
|
|
(config.maxAssignmentsPerJuror as number) ??
|
||
|
|
20
|
||
|
|
|
||
|
|
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
|
||
|
|
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
|
||
|
|
const assignmentsToMove = await prisma.assignment.findMany({
|
||
|
|
where: {
|
||
|
|
roundId: params.roundId,
|
||
|
|
userId: params.droppedJurorId,
|
||
|
|
OR: [
|
||
|
|
{ evaluation: null },
|
||
|
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
projectId: true,
|
||
|
|
juryGroupId: true,
|
||
|
|
isRequired: true,
|
||
|
|
createdAt: true,
|
||
|
|
project: { select: { title: true } },
|
||
|
|
},
|
||
|
|
orderBy: { createdAt: 'asc' },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (assignmentsToMove.length === 0) {
|
||
|
|
return {
|
||
|
|
movedCount: 0,
|
||
|
|
failedCount: 0,
|
||
|
|
failedProjects: [] as string[],
|
||
|
|
reassignedTo: {} as Record<string, number>,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||
|
|
|
||
|
|
if (round.juryGroupId) {
|
||
|
|
const members = await 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 !== params.droppedJurorId)
|
||
|
|
.map((m) => m.user)
|
||
|
|
} else {
|
||
|
|
// 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 },
|
||
|
|
roles: { has: 'JURY_MEMBER' },
|
||
|
|
status: 'ACTIVE',
|
||
|
|
},
|
||
|
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||
|
|
})
|
||
|
|
: []
|
||
|
|
}
|
||
|
|
|
||
|
|
if (candidateJurors.length === 0) {
|
||
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidateIds = candidateJurors.map((j) => j.id)
|
||
|
|
|
||
|
|
const existingAssignments = await prisma.assignment.findMany({
|
||
|
|
where: { roundId: params.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 prisma.conflictOfInterest.findMany({
|
||
|
|
where: {
|
||
|
|
roundId: params.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)
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||
|
|
const plannedMoves: {
|
||
|
|
assignmentId: string
|
||
|
|
projectId: string
|
||
|
|
projectTitle: string
|
||
|
|
newJurorId: string
|
||
|
|
juryGroupId: string | null
|
||
|
|
isRequired: boolean
|
||
|
|
}[] = []
|
||
|
|
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) => {
|
||
|
|
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
||
|
|
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 moves inside a transaction with per-move TOCTOU guard.
|
||
|
|
// Uses conditional deleteMany so a concurrent evaluation submission
|
||
|
|
// (which sets status to SUBMITTED) causes the delete to return count=0
|
||
|
|
// instead of cascade-destroying the submitted evaluation.
|
||
|
|
const actualMoves: typeof plannedMoves = []
|
||
|
|
const skippedProjects: string[] = []
|
||
|
|
|
||
|
|
if (plannedMoves.length > 0) {
|
||
|
|
await prisma.$transaction(async (tx) => {
|
||
|
|
for (const move of plannedMoves) {
|
||
|
|
// Guard: only delete if the assignment still belongs to the dropped juror
|
||
|
|
// AND its evaluation (if any) is still in a movable state.
|
||
|
|
// If a juror submitted between our read and now, count will be 0.
|
||
|
|
const deleted = await tx.assignment.deleteMany({
|
||
|
|
where: {
|
||
|
|
id: move.assignmentId,
|
||
|
|
userId: params.droppedJurorId,
|
||
|
|
OR: [
|
||
|
|
{ evaluation: null },
|
||
|
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (deleted.count === 0) {
|
||
|
|
// Assignment was already moved, deleted, or its evaluation was submitted
|
||
|
|
skippedProjects.push(move.projectTitle)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
await tx.assignment.create({
|
||
|
|
data: {
|
||
|
|
roundId: params.roundId,
|
||
|
|
projectId: move.projectId,
|
||
|
|
userId: move.newJurorId,
|
||
|
|
juryGroupId: move.juryGroupId ?? undefined,
|
||
|
|
isRequired: move.isRequired,
|
||
|
|
method: 'MANUAL',
|
||
|
|
createdBy: params.auditUserId ?? undefined,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
actualMoves.push(move)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add skipped projects to the failed list
|
||
|
|
failedProjects.push(...skippedProjects)
|
||
|
|
|
||
|
|
const reassignedTo: Record<string, number> = {}
|
||
|
|
for (const move of actualMoves) {
|
||
|
|
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actualMoves.length > 0) {
|
||
|
|
// Build per-juror project name lists for proper emails
|
||
|
|
const destProjectNames: Record<string, string[]> = {}
|
||
|
|
for (const move of actualMoves) {
|
||
|
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||
|
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||
|
|
}
|
||
|
|
|
||
|
|
const droppedName = droppedJuror.name || droppedJuror.email
|
||
|
|
|
||
|
|
// Fetch round deadline for email
|
||
|
|
const roundFull = await prisma.round.findUnique({
|
||
|
|
where: { id: params.roundId },
|
||
|
|
select: { windowCloseAt: true },
|
||
|
|
})
|
||
|
|
const deadline = roundFull?.windowCloseAt
|
||
|
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
||
|
|
: undefined
|
||
|
|
|
||
|
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||
|
|
const count = projectNames.length
|
||
|
|
await createNotification({
|
||
|
|
userId: jurorId,
|
||
|
|
type: NotificationTypes.DROPOUT_REASSIGNED,
|
||
|
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||
|
|
message: count === 1
|
||
|
|
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
||
|
|
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
||
|
|
linkUrl: `/jury/competitions`,
|
||
|
|
linkLabel: 'View Assignments',
|
||
|
|
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const topReceivers = Object.entries(reassignedTo)
|
||
|
|
.map(([jurorId, count]) => {
|
||
|
|
const juror = candidateMeta.get(jurorId)
|
||
|
|
return `${juror?.name || juror?.email || jurorId} (${count})`
|
||
|
|
})
|
||
|
|
.join(', ')
|
||
|
|
|
||
|
|
await notifyAdmins({
|
||
|
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||
|
|
title: 'Juror Dropout Reshuffle',
|
||
|
|
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`,
|
||
|
|
linkUrl: `/admin/rounds/${round.id}`,
|
||
|
|
linkLabel: 'View Round',
|
||
|
|
metadata: {
|
||
|
|
roundId: round.id,
|
||
|
|
droppedJurorId: droppedJuror.id,
|
||
|
|
movedCount: actualMoves.length,
|
||
|
|
failedCount: failedProjects.length,
|
||
|
|
topReceivers,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove the dropped juror from the jury group so they can't be re-assigned
|
||
|
|
// in future assignment runs for this round's competition.
|
||
|
|
let removedFromGroup = false
|
||
|
|
if (round.juryGroupId) {
|
||
|
|
const deleted = await prisma.juryGroupMember.deleteMany({
|
||
|
|
where: {
|
||
|
|
juryGroupId: round.juryGroupId,
|
||
|
|
userId: params.droppedJurorId,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
removedFromGroup = deleted.count > 0
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
action: 'JUROR_DROPOUT_RESHUFFLE',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: round.id,
|
||
|
|
detailsJson: {
|
||
|
|
droppedJurorId: droppedJuror.id,
|
||
|
|
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
||
|
|
movedCount: actualMoves.length,
|
||
|
|
failedCount: failedProjects.length,
|
||
|
|
failedProjects,
|
||
|
|
skippedProjects,
|
||
|
|
reassignedTo,
|
||
|
|
removedFromGroup,
|
||
|
|
moves: moveDetails,
|
||
|
|
},
|
||
|
|
ipAddress: params.auditIp,
|
||
|
|
userAgent: params.auditUserAgent,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
movedCount: actualMoves.length,
|
||
|
|
failedCount: failedProjects.length,
|
||
|
|
failedProjects,
|
||
|
|
reassignedTo,
|
||
|
|
}
|
||
|
|
}
|