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>
2242 lines
73 KiB
TypeScript
2242 lines
73 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
|
import {
|
|
generateAIAssignments,
|
|
generateFallbackAssignments,
|
|
type AssignmentProgressCallback,
|
|
} from '../services/ai-assignment'
|
|
import { isOpenAIConfigured } from '@/lib/openai'
|
|
import { prisma } from '@/lib/prisma'
|
|
import {
|
|
createNotification,
|
|
createBulkNotifications,
|
|
notifyAdmins,
|
|
NotificationTypes,
|
|
} from '../services/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
|
|
|
|
// Get all jurors already assigned to this project in this round
|
|
const existingAssignments = await prisma.assignment.findMany({
|
|
where: { roundId, projectId },
|
|
select: { userId: true },
|
|
})
|
|
const alreadyAssignedIds = new Set(existingAssignments.map((a) => a.userId))
|
|
|
|
// Get all COI records for this project (any juror who declared conflict)
|
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
|
where: { projectId, hasConflict: true },
|
|
select: { userId: true },
|
|
})
|
|
const coiUserIds = new Set(coiRecords.map((c) => c.userId))
|
|
|
|
// Find eligible jurors: in the jury group (or all JURY_MEMBERs), not already assigned, no COI
|
|
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 },
|
|
role: 'JURY_MEMBER',
|
|
status: 'ACTIVE',
|
|
},
|
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
|
})
|
|
: []
|
|
}
|
|
|
|
// Filter out already assigned and COI jurors
|
|
const eligible = candidateJurors.filter(
|
|
(j) => !alreadyAssignedIds.has(j.id) && !coiUserIds.has(j.id)
|
|
)
|
|
|
|
if (eligible.length === 0) return null
|
|
|
|
// Get current assignment counts for eligible jurors in this round
|
|
const counts = await prisma.assignment.groupBy({
|
|
by: ['userId'],
|
|
where: { roundId, userId: { in: eligible.map((j) => j.id) } },
|
|
_count: true,
|
|
})
|
|
const countMap = new Map(counts.map((c) => [c.userId, c._count]))
|
|
|
|
// Find jurors under their limit, sorted by fewest assignments (load balancing)
|
|
const underLimit = eligible
|
|
.map((j) => ({
|
|
...j,
|
|
currentCount: countMap.get(j.id) || 0,
|
|
effectiveMax: j.maxAssignments ?? maxAssignmentsPerJuror,
|
|
}))
|
|
.filter((j) => j.currentCount < j.effectiveMax)
|
|
.sort((a, b) => a.currentCount - b.currentCount)
|
|
|
|
if (underLimit.length === 0) return null
|
|
|
|
const replacement = underLimit[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
|
|
await createNotification({
|
|
userId: replacement.id,
|
|
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
|
title: 'New Project Assigned',
|
|
message: `You have been assigned to evaluate "${assignment.project.title}" for ${assignment.round.name}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignment',
|
|
metadata: { projectId, roundName: assignment.round.name },
|
|
})
|
|
|
|
// Notify admins of the reassignment
|
|
await notifyAdmins({
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
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}`,
|
|
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
|
|
|
|
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 },
|
|
role: '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) {
|
|
await createBulkNotifications({
|
|
userIds: Object.keys(reassignedTo),
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: 'Additional Projects Assigned',
|
|
message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
|
})
|
|
|
|
const droppedName = droppedJuror.name || droppedJuror.email
|
|
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.BATCH_ASSIGNED,
|
|
title: 'Juror Dropout Reshuffle',
|
|
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned.'}`,
|
|
linkUrl: `/admin/rounds/${round.id}`,
|
|
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,
|
|
}
|
|
}
|
|
|
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
|
try {
|
|
await prisma.assignmentJob.update({
|
|
where: { id: jobId },
|
|
data: { status: 'RUNNING', startedAt: new Date() },
|
|
})
|
|
|
|
const round = await prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
select: {
|
|
name: true,
|
|
configJson: true,
|
|
competitionId: true,
|
|
juryGroupId: true,
|
|
},
|
|
})
|
|
|
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
|
const minAssignmentsPerJuror =
|
|
(config.minLoadPerJuror as number) ??
|
|
(config.minAssignmentsPerJuror as number) ??
|
|
1
|
|
const maxAssignmentsPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
// Scope jurors to jury group if the round has one assigned
|
|
let scopedJurorIds: string[] | undefined
|
|
if (round.juryGroupId) {
|
|
const groupMembers = await prisma.juryGroupMember.findMany({
|
|
where: { juryGroupId: round.juryGroupId },
|
|
select: { userId: true },
|
|
})
|
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
|
}
|
|
|
|
const jurors = await prisma.user.findMany({
|
|
where: {
|
|
role: 'JURY_MEMBER',
|
|
status: 'ACTIVE',
|
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
expertiseTags: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const projectRoundStates = await prisma.projectRoundState.findMany({
|
|
where: { roundId },
|
|
select: { projectId: true },
|
|
})
|
|
const projectIds = projectRoundStates.map((prs) => prs.projectId)
|
|
|
|
const projects = await prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
tags: true,
|
|
teamName: true,
|
|
projectTags: {
|
|
select: { tag: { select: { name: true } }, confidence: true },
|
|
},
|
|
_count: { select: { assignments: { where: { roundId } } } },
|
|
},
|
|
})
|
|
|
|
// Enrich projects with tag confidence data for AI matching
|
|
const projectsWithConfidence = projects.map((p) => ({
|
|
...p,
|
|
tagConfidences: p.projectTags.map((pt) => ({
|
|
name: pt.tag.name,
|
|
confidence: pt.confidence,
|
|
})),
|
|
}))
|
|
|
|
const existingAssignments = await prisma.assignment.findMany({
|
|
where: { roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
|
|
// Query COI records for this round to exclude conflicted juror-project pairs
|
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
|
where: {
|
|
roundId,
|
|
hasConflict: true,
|
|
},
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const coiExclusions = new Set(
|
|
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
|
)
|
|
|
|
// Calculate batch info
|
|
const BATCH_SIZE = 15
|
|
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
|
|
|
await prisma.assignmentJob.update({
|
|
where: { id: jobId },
|
|
data: { totalProjects: projects.length, totalBatches },
|
|
})
|
|
|
|
// Progress callback
|
|
const onProgress: AssignmentProgressCallback = async (progress) => {
|
|
await prisma.assignmentJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
currentBatch: progress.currentBatch,
|
|
processedCount: progress.processedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Build per-juror limits map for jurors with personal maxAssignments
|
|
const jurorLimits: Record<string, number> = {}
|
|
for (const juror of jurors) {
|
|
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
|
|
jurorLimits[juror.id] = juror.maxAssignments
|
|
}
|
|
}
|
|
|
|
const constraints = {
|
|
requiredReviewsPerProject: requiredReviews,
|
|
minAssignmentsPerJuror,
|
|
maxAssignmentsPerJuror,
|
|
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
|
|
existingAssignments: existingAssignments.map((a) => ({
|
|
jurorId: a.userId,
|
|
projectId: a.projectId,
|
|
})),
|
|
}
|
|
|
|
const result = await generateAIAssignments(
|
|
jurors,
|
|
projectsWithConfidence,
|
|
constraints,
|
|
userId,
|
|
roundId,
|
|
onProgress
|
|
)
|
|
|
|
// Filter out suggestions that conflict with COI declarations
|
|
const filteredSuggestions = coiExclusions.size > 0
|
|
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
|
|
: result.suggestions
|
|
|
|
// Enrich suggestions with names for storage
|
|
const enrichedSuggestions = filteredSuggestions.map((s) => {
|
|
const juror = jurors.find((j) => j.id === s.jurorId)
|
|
const project = projects.find((p) => p.id === s.projectId)
|
|
return {
|
|
...s,
|
|
jurorName: juror?.name || juror?.email || 'Unknown',
|
|
projectTitle: project?.title || 'Unknown',
|
|
}
|
|
})
|
|
|
|
// Mark job as completed and store suggestions
|
|
await prisma.assignmentJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: 'COMPLETED',
|
|
completedAt: new Date(),
|
|
processedCount: projects.length,
|
|
suggestionsCount: filteredSuggestions.length,
|
|
suggestionsJson: enrichedSuggestions,
|
|
fallbackUsed: result.fallbackUsed ?? false,
|
|
},
|
|
})
|
|
|
|
await notifyAdmins({
|
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
|
title: 'AI Assignment Suggestions Ready',
|
|
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
|
linkUrl: `/admin/rounds/${roundId}`,
|
|
linkLabel: 'View Suggestions',
|
|
priority: 'high',
|
|
metadata: {
|
|
roundId,
|
|
jobId,
|
|
projectCount: projects.length,
|
|
suggestionsCount: filteredSuggestions.length,
|
|
fallbackUsed: result.fallbackUsed,
|
|
},
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error('[AI Assignment Job] Error:', error)
|
|
|
|
// Mark job as failed
|
|
await prisma.assignmentJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: 'FAILED',
|
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
completedAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
export const assignmentRouter = router({
|
|
listByStage: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
|
project: { select: { id: true, title: true, tags: true } },
|
|
evaluation: { select: { status: true, submittedAt: true } },
|
|
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* List assignments for a project (admin only)
|
|
*/
|
|
listByProject: adminProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: { projectId: input.projectId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
|
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
|
|
// Attach avatar URLs
|
|
return Promise.all(
|
|
assignments.map(async (a) => ({
|
|
...a,
|
|
user: {
|
|
...a.user,
|
|
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
|
},
|
|
}))
|
|
)
|
|
}),
|
|
|
|
/**
|
|
* Get my assignments (for jury members)
|
|
*/
|
|
myAssignments: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string().optional(),
|
|
status: z.enum(['all', 'pending', 'completed']).default('all'),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Record<string, unknown> = {
|
|
userId: ctx.user.id,
|
|
}
|
|
|
|
if (input.roundId) {
|
|
where.roundId = input.roundId
|
|
}
|
|
|
|
if (input.status === 'pending') {
|
|
where.isCompleted = false
|
|
} else if (input.status === 'completed') {
|
|
where.isCompleted = true
|
|
}
|
|
|
|
return ctx.prisma.assignment.findMany({
|
|
where,
|
|
include: {
|
|
project: {
|
|
include: { files: true },
|
|
},
|
|
round: true,
|
|
evaluation: true,
|
|
},
|
|
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get assignment by ID
|
|
*/
|
|
get: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
project: { include: { files: true } },
|
|
round: { include: { evaluationForms: { where: { isActive: true } } } },
|
|
evaluation: true,
|
|
},
|
|
})
|
|
|
|
// Verify access
|
|
if (
|
|
ctx.user.role === 'JURY_MEMBER' &&
|
|
assignment.userId !== ctx.user.id
|
|
) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You do not have access to this assignment',
|
|
})
|
|
}
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Create a single assignment (admin only)
|
|
*/
|
|
create: adminProcedure
|
|
.input(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
isRequired: z.boolean().default(true),
|
|
forceOverride: z.boolean().default(false),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.prisma.assignment.findUnique({
|
|
where: {
|
|
userId_projectId_roundId: {
|
|
userId: input.userId,
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'This assignment already exists',
|
|
})
|
|
}
|
|
|
|
const [stage, user] = await Promise.all([
|
|
ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true },
|
|
}),
|
|
ctx.prisma.user.findUniqueOrThrow({
|
|
where: { id: input.userId },
|
|
select: { maxAssignments: true, name: true },
|
|
}),
|
|
])
|
|
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
|
const maxAssignmentsPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
|
|
|
|
const currentCount = await ctx.prisma.assignment.count({
|
|
where: { userId: input.userId, roundId: input.roundId },
|
|
})
|
|
|
|
// Check if at or over limit
|
|
if (currentCount >= effectiveMax) {
|
|
if (!input.forceOverride) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `${user.name || 'Judge'} has reached their maximum limit of ${effectiveMax} projects. Use manual override to proceed.`,
|
|
})
|
|
}
|
|
// Log the override in audit
|
|
console.log(`[Assignment] Manual override: Assigning ${user.name} beyond limit (${currentCount}/${effectiveMax})`)
|
|
}
|
|
|
|
const { forceOverride: _override, ...assignmentData } = input
|
|
const assignment = await ctx.prisma.assignment.create({
|
|
data: {
|
|
...assignmentData,
|
|
method: 'MANUAL',
|
|
createdBy: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'Assignment',
|
|
entityId: assignment.id,
|
|
detailsJson: input,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
const [project, stageInfo] = await Promise.all([
|
|
ctx.prisma.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { title: true },
|
|
}),
|
|
ctx.prisma.round.findUnique({
|
|
where: { id: input.roundId },
|
|
select: { name: true, windowCloseAt: true },
|
|
}),
|
|
])
|
|
|
|
if (project && stageInfo) {
|
|
const deadline = stageInfo.windowCloseAt
|
|
? new Date(stageInfo.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
await createNotification({
|
|
userId: input.userId,
|
|
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
|
title: 'New Project Assignment',
|
|
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignment',
|
|
metadata: {
|
|
projectName: project.title,
|
|
roundName: stageInfo.name,
|
|
deadline,
|
|
assignmentId: assignment.id,
|
|
},
|
|
})
|
|
}
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Bulk create assignments (admin only)
|
|
*/
|
|
bulkCreate: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Fetch per-juror maxAssignments and current counts for capacity checking
|
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
|
const users = await ctx.prisma.user.findMany({
|
|
where: { id: { in: uniqueUserIds } },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
|
|
|
// Get stage default max
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true, name: true, windowCloseAt: true },
|
|
})
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
|
const stageMaxPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
// Track running counts to handle multiple assignments to the same juror in one batch
|
|
const runningCounts = new Map<string, number>()
|
|
for (const u of users) {
|
|
runningCounts.set(u.id, u._count.assignments)
|
|
}
|
|
|
|
// Filter out assignments that would exceed a juror's limit
|
|
let skippedDueToCapacity = 0
|
|
const allowedAssignments = input.assignments.filter((a) => {
|
|
const user = userMap.get(a.userId)
|
|
if (!user) return true // unknown user, let createMany handle it
|
|
|
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
|
|
|
if (currentCount >= effectiveMax) {
|
|
skippedDueToCapacity++
|
|
return false
|
|
}
|
|
|
|
// Increment running count for subsequent assignments to same user
|
|
runningCounts.set(a.userId, currentCount + 1)
|
|
return true
|
|
})
|
|
|
|
const result = await ctx.prisma.assignment.createMany({
|
|
data: allowedAssignments.map((a) => ({
|
|
...a,
|
|
roundId: input.roundId,
|
|
method: 'BULK',
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'BULK_CREATE',
|
|
entityType: 'Assignment',
|
|
detailsJson: {
|
|
count: result.count,
|
|
requested: input.assignments.length,
|
|
skippedDueToCapacity,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
// Send notifications to assigned jury members (grouped by user)
|
|
if (result.count > 0 && allowedAssignments.length > 0) {
|
|
// Group assignments by user to get counts
|
|
const userAssignmentCounts = allowedAssignments.reduce(
|
|
(acc, a) => {
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>
|
|
)
|
|
|
|
const deadline = stage?.windowCloseAt
|
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
existing.push(userId)
|
|
usersByProjectCount.set(projectCount, existing)
|
|
}
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
if (userIds.length === 0) continue
|
|
await createBulkNotifications({
|
|
userIds,
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: `${projectCount} Projects Assigned`,
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: {
|
|
projectCount,
|
|
roundName: stage?.name,
|
|
deadline,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
created: result.count,
|
|
requested: input.assignments.length,
|
|
skipped: input.assignments.length - result.count,
|
|
skippedDueToCapacity,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Delete an assignment (admin only)
|
|
*/
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const assignment = await ctx.prisma.assignment.delete({
|
|
where: { id: input.id },
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'Assignment',
|
|
entityId: input.id,
|
|
detailsJson: {
|
|
userId: assignment.userId,
|
|
projectId: assignment.projectId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Get assignment statistics for a round
|
|
*/
|
|
getStats: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true },
|
|
})
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
|
|
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { projectId: true },
|
|
})
|
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
|
|
|
const [
|
|
totalAssignments,
|
|
completedAssignments,
|
|
assignmentsByUser,
|
|
projectCoverage,
|
|
] = await Promise.all([
|
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
|
ctx.prisma.assignment.count({
|
|
where: { roundId: input.roundId, isCompleted: true },
|
|
}),
|
|
ctx.prisma.assignment.groupBy({
|
|
by: ['userId'],
|
|
where: { roundId: input.roundId },
|
|
_count: true,
|
|
}),
|
|
ctx.prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
|
},
|
|
}),
|
|
])
|
|
|
|
const projectsWithFullCoverage = projectCoverage.filter(
|
|
(p) => p._count.assignments >= requiredReviews
|
|
).length
|
|
|
|
return {
|
|
totalAssignments,
|
|
completedAssignments,
|
|
completionPercentage:
|
|
totalAssignments > 0
|
|
? Math.round((completedAssignments / totalAssignments) * 100)
|
|
: 0,
|
|
juryMembersAssigned: assignmentsByUser.length,
|
|
projectsWithFullCoverage,
|
|
totalProjects: projectCoverage.length,
|
|
coveragePercentage:
|
|
projectCoverage.length > 0
|
|
? Math.round(
|
|
(projectsWithFullCoverage / projectCoverage.length) * 100
|
|
)
|
|
: 0,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get smart assignment suggestions using algorithm
|
|
*/
|
|
getSuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true, juryGroupId: true },
|
|
})
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
|
const minAssignmentsPerJuror =
|
|
(config.minLoadPerJuror as number) ??
|
|
(config.minAssignmentsPerJuror as number) ??
|
|
1
|
|
const maxAssignmentsPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
// Extract category quotas if enabled
|
|
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
|
|
const categoryQuotas = categoryQuotasEnabled
|
|
? (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',
|
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
expertiseTags: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { projectId: true },
|
|
})
|
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
tags: true,
|
|
competitionCategory: true,
|
|
projectTags: {
|
|
include: { tag: { select: { name: true } } },
|
|
},
|
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
|
},
|
|
})
|
|
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const assignmentSet = new Set(
|
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
|
)
|
|
|
|
// Build per-juror category distribution for quota scoring
|
|
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
|
if (categoryQuotas) {
|
|
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: {
|
|
userId: true,
|
|
project: { select: { competitionCategory: true } },
|
|
},
|
|
})
|
|
for (const a of assignmentsWithCategory) {
|
|
const cat = a.project.competitionCategory?.toLowerCase().trim()
|
|
if (!cat) continue
|
|
let catMap = jurorCategoryDistribution.get(a.userId)
|
|
if (!catMap) {
|
|
catMap = {}
|
|
jurorCategoryDistribution.set(a.userId, catMap)
|
|
}
|
|
catMap[cat] = (catMap[cat] || 0) + 1
|
|
}
|
|
}
|
|
|
|
const suggestions: Array<{
|
|
userId: string
|
|
jurorName: string
|
|
projectId: string
|
|
projectTitle: string
|
|
score: number
|
|
reasoning: string[]
|
|
}> = []
|
|
|
|
for (const project of projects) {
|
|
if (project._count.assignments >= requiredReviews) continue
|
|
|
|
const neededAssignments = requiredReviews - project._count.assignments
|
|
|
|
const jurorScores = jurors
|
|
.filter((j) => {
|
|
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
|
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
|
if (j._count.assignments >= effectiveMax) return false
|
|
return true
|
|
})
|
|
.map((juror) => {
|
|
const reasoning: string[] = []
|
|
let score = 0
|
|
|
|
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
|
|
|
|
const matchingTags = projectTagNames.length > 0
|
|
? juror.expertiseTags.filter((tag) =>
|
|
projectTagNames.includes(tag.toLowerCase())
|
|
)
|
|
: juror.expertiseTags.filter((tag) =>
|
|
project.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
|
|
)
|
|
|
|
const totalTags = projectTagNames.length > 0 ? projectTagNames.length : project.tags.length
|
|
const expertiseScore =
|
|
matchingTags.length > 0
|
|
? matchingTags.length / Math.max(totalTags, 1)
|
|
: 0
|
|
score += expertiseScore * 35
|
|
if (matchingTags.length > 0) {
|
|
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
|
|
}
|
|
|
|
const effectiveMax = juror.maxAssignments ?? maxAssignmentsPerJuror
|
|
const loadScore = 1 - juror._count.assignments / effectiveMax
|
|
score += loadScore * 20
|
|
|
|
const underMinBonus =
|
|
juror._count.assignments < minAssignmentsPerJuror
|
|
? (minAssignmentsPerJuror - juror._count.assignments) * 3
|
|
: 0
|
|
score += Math.min(15, underMinBonus)
|
|
|
|
if (juror._count.assignments < minAssignmentsPerJuror) {
|
|
reasoning.push(
|
|
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
|
|
)
|
|
}
|
|
reasoning.push(
|
|
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
|
)
|
|
|
|
// Category quota scoring
|
|
if (categoryQuotas) {
|
|
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
|
|
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
|
|
if (normalizedCat) {
|
|
const quota = Object.entries(categoryQuotas).find(
|
|
([key]) => key.toLowerCase().trim() === normalizedCat
|
|
)
|
|
if (quota) {
|
|
const [, { min, max }] = quota
|
|
const currentCount = jurorCategoryCounts[normalizedCat] || 0
|
|
if (currentCount >= max) {
|
|
score -= 25
|
|
reasoning.push(`Category quota exceeded (-25)`)
|
|
} else if (currentCount < min) {
|
|
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
|
if (key.toLowerCase().trim() === normalizedCat) return false
|
|
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
|
|
})
|
|
if (otherAboveMin) {
|
|
score += 10
|
|
reasoning.push(`Category quota bonus (+10)`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
userId: juror.id,
|
|
jurorName: juror.name || juror.email || 'Unknown',
|
|
projectId: project.id,
|
|
projectTitle: project.title || 'Unknown',
|
|
score,
|
|
reasoning,
|
|
}
|
|
})
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, neededAssignments)
|
|
|
|
suggestions.push(...jurorScores)
|
|
}
|
|
|
|
return suggestions.sort((a, b) => b.score - a.score)
|
|
}),
|
|
|
|
/**
|
|
* Check if AI assignment is available
|
|
*/
|
|
isAIAvailable: adminProcedure.query(async () => {
|
|
return isOpenAIConfigured()
|
|
}),
|
|
|
|
/**
|
|
* Get AI-powered assignment suggestions (retrieves from completed job)
|
|
*/
|
|
getAISuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
useAI: z.boolean().default(true),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const completedJob = await ctx.prisma.assignmentJob.findFirst({
|
|
where: {
|
|
roundId: input.roundId,
|
|
status: 'COMPLETED',
|
|
},
|
|
orderBy: { completedAt: 'desc' },
|
|
select: {
|
|
suggestionsJson: true,
|
|
fallbackUsed: true,
|
|
completedAt: true,
|
|
},
|
|
})
|
|
|
|
if (completedJob?.suggestionsJson) {
|
|
const suggestions = completedJob.suggestionsJson as Array<{
|
|
jurorId: string
|
|
jurorName: string
|
|
projectId: string
|
|
projectTitle: string
|
|
confidenceScore: number
|
|
expertiseMatchScore: number
|
|
reasoning: string
|
|
}>
|
|
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const assignmentSet = new Set(
|
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
|
)
|
|
|
|
const filteredSuggestions = suggestions.filter(
|
|
(s) => !assignmentSet.has(`${s.jurorId}-${s.projectId}`)
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
suggestions: filteredSuggestions,
|
|
fallbackUsed: completedJob.fallbackUsed,
|
|
error: null,
|
|
generatedAt: completedJob.completedAt,
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
suggestions: [],
|
|
fallbackUsed: false,
|
|
error: null,
|
|
generatedAt: null,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Apply AI-suggested assignments
|
|
*/
|
|
applyAISuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
confidenceScore: z.number().optional(),
|
|
expertiseMatchScore: z.number().optional(),
|
|
reasoning: z.string().optional(),
|
|
})
|
|
),
|
|
usedAI: z.boolean().default(false),
|
|
forceOverride: z.boolean().default(false),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
let assignmentsToCreate = input.assignments
|
|
let skippedDueToCapacity = 0
|
|
|
|
// Capacity check (unless forceOverride)
|
|
if (!input.forceOverride) {
|
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
|
const users = await ctx.prisma.user.findMany({
|
|
where: { id: { in: uniqueUserIds } },
|
|
select: {
|
|
id: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
|
|
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true },
|
|
})
|
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
|
const stageMaxPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
const runningCounts = new Map<string, number>()
|
|
for (const u of users) {
|
|
runningCounts.set(u.id, u._count.assignments)
|
|
}
|
|
|
|
assignmentsToCreate = input.assignments.filter((a) => {
|
|
const user = userMap.get(a.userId)
|
|
if (!user) return true
|
|
|
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
|
|
|
if (currentCount >= effectiveMax) {
|
|
skippedDueToCapacity++
|
|
return false
|
|
}
|
|
|
|
runningCounts.set(a.userId, currentCount + 1)
|
|
return true
|
|
})
|
|
}
|
|
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
data: assignmentsToCreate.map((a) => ({
|
|
userId: a.userId,
|
|
projectId: a.projectId,
|
|
roundId: input.roundId,
|
|
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
|
|
aiConfidenceScore: a.confidenceScore,
|
|
expertiseMatchScore: a.expertiseMatchScore,
|
|
aiReasoning: a.reasoning,
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
|
entityType: 'Assignment',
|
|
detailsJson: {
|
|
roundId: input.roundId,
|
|
count: created.count,
|
|
usedAI: input.usedAI,
|
|
forceOverride: input.forceOverride,
|
|
skippedDueToCapacity,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
if (created.count > 0) {
|
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
|
(acc, a) => {
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>
|
|
)
|
|
|
|
const stage = await ctx.prisma.round.findUnique({
|
|
where: { id: input.roundId },
|
|
select: { name: true, windowCloseAt: true },
|
|
})
|
|
|
|
const deadline = stage?.windowCloseAt
|
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
existing.push(userId)
|
|
usersByProjectCount.set(projectCount, existing)
|
|
}
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
if (userIds.length === 0) continue
|
|
await createBulkNotifications({
|
|
userIds,
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: `${projectCount} Projects Assigned`,
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: {
|
|
projectCount,
|
|
roundName: stage?.name,
|
|
deadline,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
created: created.count,
|
|
requested: input.assignments.length,
|
|
skippedDueToCapacity,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Apply suggested assignments
|
|
*/
|
|
applySuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
reasoning: z.string().optional(),
|
|
})
|
|
),
|
|
forceOverride: z.boolean().default(false),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
let assignmentsToCreate = input.assignments
|
|
let skippedDueToCapacity = 0
|
|
|
|
// Capacity check (unless forceOverride)
|
|
if (!input.forceOverride) {
|
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
|
const users = await ctx.prisma.user.findMany({
|
|
where: { id: { in: uniqueUserIds } },
|
|
select: {
|
|
id: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
|
|
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { configJson: true },
|
|
})
|
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
|
const stageMaxPerJuror =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
const runningCounts = new Map<string, number>()
|
|
for (const u of users) {
|
|
runningCounts.set(u.id, u._count.assignments)
|
|
}
|
|
|
|
assignmentsToCreate = input.assignments.filter((a) => {
|
|
const user = userMap.get(a.userId)
|
|
if (!user) return true
|
|
|
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
|
|
|
if (currentCount >= effectiveMax) {
|
|
skippedDueToCapacity++
|
|
return false
|
|
}
|
|
|
|
runningCounts.set(a.userId, currentCount + 1)
|
|
return true
|
|
})
|
|
}
|
|
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
data: assignmentsToCreate.map((a) => ({
|
|
userId: a.userId,
|
|
projectId: a.projectId,
|
|
roundId: input.roundId,
|
|
method: 'ALGORITHM',
|
|
aiReasoning: a.reasoning,
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'APPLY_SUGGESTIONS',
|
|
entityType: 'Assignment',
|
|
detailsJson: {
|
|
roundId: input.roundId,
|
|
count: created.count,
|
|
forceOverride: input.forceOverride,
|
|
skippedDueToCapacity,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
if (created.count > 0) {
|
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
|
(acc, a) => {
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>
|
|
)
|
|
|
|
const stage = await ctx.prisma.round.findUnique({
|
|
where: { id: input.roundId },
|
|
select: { name: true, windowCloseAt: true },
|
|
})
|
|
|
|
const deadline = stage?.windowCloseAt
|
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
existing.push(userId)
|
|
usersByProjectCount.set(projectCount, existing)
|
|
}
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
if (userIds.length === 0) continue
|
|
await createBulkNotifications({
|
|
userIds,
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: `${projectCount} Projects Assigned`,
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: {
|
|
projectCount,
|
|
roundName: stage?.name,
|
|
deadline,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
created: created.count,
|
|
requested: input.assignments.length,
|
|
skippedDueToCapacity,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Start an AI assignment job (background processing)
|
|
*/
|
|
startAIAssignmentJob: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
|
where: {
|
|
roundId: input.roundId,
|
|
status: { in: ['PENDING', 'RUNNING'] },
|
|
},
|
|
})
|
|
|
|
if (existingJob) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'An AI assignment job is already running for this stage',
|
|
})
|
|
}
|
|
|
|
if (!isOpenAIConfigured()) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'OpenAI API is not configured',
|
|
})
|
|
}
|
|
|
|
const job = await ctx.prisma.assignmentJob.create({
|
|
data: {
|
|
roundId: input.roundId,
|
|
status: 'PENDING',
|
|
},
|
|
})
|
|
|
|
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
|
|
|
return { jobId: job.id }
|
|
}),
|
|
|
|
/**
|
|
* Get AI assignment job status (for polling)
|
|
*/
|
|
getAIAssignmentJobStatus: adminProcedure
|
|
.input(z.object({ jobId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({
|
|
where: { id: input.jobId },
|
|
})
|
|
|
|
return {
|
|
id: job.id,
|
|
status: job.status,
|
|
totalProjects: job.totalProjects,
|
|
totalBatches: job.totalBatches,
|
|
currentBatch: job.currentBatch,
|
|
processedCount: job.processedCount,
|
|
suggestionsCount: job.suggestionsCount,
|
|
fallbackUsed: job.fallbackUsed,
|
|
errorMessage: job.errorMessage,
|
|
startedAt: job.startedAt,
|
|
completedAt: job.completedAt,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get the latest AI assignment job for a round
|
|
*/
|
|
getLatestAIAssignmentJob: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const job = await ctx.prisma.assignmentJob.findFirst({
|
|
where: { roundId: input.roundId },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
|
|
if (!job) return null
|
|
|
|
return {
|
|
id: job.id,
|
|
status: job.status,
|
|
totalProjects: job.totalProjects,
|
|
totalBatches: job.totalBatches,
|
|
currentBatch: job.currentBatch,
|
|
processedCount: job.processedCount,
|
|
suggestionsCount: job.suggestionsCount,
|
|
fallbackUsed: job.fallbackUsed,
|
|
errorMessage: job.errorMessage,
|
|
startedAt: job.startedAt,
|
|
completedAt: job.completedAt,
|
|
createdAt: job.createdAt,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Notify all jurors of their current assignments for a round (admin only).
|
|
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
|
*/
|
|
notifyJurorsOfAssignments: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { name: true, windowCloseAt: true },
|
|
})
|
|
|
|
// Get all assignments grouped by user
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true },
|
|
})
|
|
|
|
if (assignments.length === 0) {
|
|
return { sent: 0, jurorCount: 0 }
|
|
}
|
|
|
|
// Count assignments per user
|
|
const userCounts: Record<string, number> = {}
|
|
for (const a of assignments) {
|
|
userCounts[a.userId] = (userCounts[a.userId] || 0) + 1
|
|
}
|
|
|
|
const deadline = round.windowCloseAt
|
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
// Create in-app notifications grouped by project count
|
|
const usersByProjectCount = new Map<number, string[]>()
|
|
for (const [userId, projectCount] of Object.entries(userCounts)) {
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
existing.push(userId)
|
|
usersByProjectCount.set(projectCount, existing)
|
|
}
|
|
|
|
let totalSent = 0
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
if (userIds.length === 0) continue
|
|
await createBulkNotifications({
|
|
userIds,
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: `${projectCount} Projects Assigned`,
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: { projectCount, roundName: round.name, deadline },
|
|
})
|
|
totalSent += userIds.length
|
|
}
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
|
entityType: 'Round',
|
|
entityId: input.roundId,
|
|
detailsJson: {
|
|
jurorCount: Object.keys(userCounts).length,
|
|
totalAssignments: assignments.length,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
|
}),
|
|
|
|
notifySingleJurorOfAssignments: adminProcedure
|
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { name: true, windowCloseAt: true },
|
|
})
|
|
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId, userId: input.userId },
|
|
select: { id: true },
|
|
})
|
|
|
|
if (assignments.length === 0) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'No assignments found for this juror in this round' })
|
|
}
|
|
|
|
const projectCount = assignments.length
|
|
const deadline = round.windowCloseAt
|
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
: undefined
|
|
|
|
await createBulkNotifications({
|
|
userIds: [input.userId],
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
title: `${projectCount} Projects Assigned`,
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: { projectCount, roundName: round.name, deadline },
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'NOTIFY_SINGLE_JUROR_OF_ASSIGNMENTS',
|
|
entityType: 'Round',
|
|
entityId: input.roundId,
|
|
detailsJson: {
|
|
targetUserId: input.userId,
|
|
assignmentCount: projectCount,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { sent: 1, projectCount }
|
|
}),
|
|
|
|
reassignCOI: adminProcedure
|
|
.input(z.object({ assignmentId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await reassignAfterCOI({
|
|
assignmentId: input.assignmentId,
|
|
auditUserId: ctx.user.id,
|
|
auditIp: ctx.ip,
|
|
auditUserAgent: ctx.userAgent,
|
|
})
|
|
|
|
if (!result) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No eligible juror found for reassignment. All jurors are either already assigned to this project, have a COI, or are at their assignment limit.',
|
|
})
|
|
}
|
|
|
|
return result
|
|
}),
|
|
|
|
reassignDroppedJuror: adminProcedure
|
|
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
return reassignDroppedJurorAssignments({
|
|
roundId: input.roundId,
|
|
droppedJurorId: input.jurorId,
|
|
auditUserId: ctx.user.id,
|
|
auditIp: ctx.ip,
|
|
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
|
|
}),
|
|
})
|