Schema: - Drop 4 dead models: OverrideAction, NotificationPolicy, AssignmentException, AdvancementRule - Drop 2 dead enums: OverrideReasonCode, AdvancementRuleType - Drop 3 stale columns: Project.roundId, ConflictOfInterest.roundId, Evaluation.version - Remove 3 back-relation fields from User, Assignment, Round Code: - Fix 6 COI queries in assignment.ts + 1 in juror-reassignment.ts (roundId filter → assignment.roundId after column drop) - Remove orphaned Project.roundId write in project.ts createProject - Remove advancementRules include from round.ts getById - Remove AdvancementRule from RoundWithRelations type - Clean up seed.ts (remove advancement rule seeding) - Clean up tests/helpers.ts (remove dead model cleanup) - Add TODO comments on user delete mutations (FK violation risk) Migration: 20260308000000_drop_dead_models_and_stale_columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2776 lines
98 KiB
TypeScript
2776 lines
98 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, protectedProcedure, adminProcedure, userHasRole, withAIRateLimit } 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'
|
|
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../services/juror-reassignment'
|
|
|
|
export { reassignAfterCOI, reassignDroppedJurorAssignments }
|
|
|
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
|
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
|
|
|
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: {
|
|
roles: { has: '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: {
|
|
assignment: { 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,
|
|
criterionScoresJson: true,
|
|
form: { select: { criteriaJson: 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 (
|
|
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
|
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') &&
|
|
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: {
|
|
roles: { has: '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
|
|
.use(withAIRateLimit)
|
|
.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,
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Redistribute all movable assignments from a juror to other jurors (without dropping them from the group).
|
|
* Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group.
|
|
* Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors.
|
|
*/
|
|
redistributeJurorAssignments: adminProcedure
|
|
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true },
|
|
})
|
|
|
|
const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({
|
|
where: { id: input.jurorId },
|
|
select: { id: true, name: true, email: true },
|
|
})
|
|
|
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
|
const fallbackCap =
|
|
(config.maxLoadPerJuror as number) ??
|
|
(config.maxAssignmentsPerJuror as number) ??
|
|
20
|
|
|
|
const assignmentsToMove = await ctx.prisma.assignment.findMany({
|
|
where: {
|
|
roundId: input.roundId,
|
|
userId: input.jurorId,
|
|
OR: [
|
|
{ evaluation: null },
|
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
|
],
|
|
},
|
|
select: {
|
|
id: true, projectId: true, juryGroupId: true, isRequired: true,
|
|
project: { select: { title: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
|
|
if (assignmentsToMove.length === 0) {
|
|
return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] }
|
|
}
|
|
|
|
// Build candidate pool
|
|
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 ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
|
candidateJurors = ids.length > 0
|
|
? await ctx.prisma.user.findMany({
|
|
where: { id: { in: ids }, 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 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: { assignment: { roundId: input.roundId }, hasConflict: true, userId: { in: candidateIds } },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
|
|
|
// Completed eval counts for "prefer not-finished" logic
|
|
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 caps = new Map<string, number>()
|
|
for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap)
|
|
|
|
const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = []
|
|
const failedProjects: string[] = []
|
|
|
|
for (const assignment of assignmentsToMove) {
|
|
// First pass: prefer jurors who haven't completed all evals
|
|
let eligible = candidateIds
|
|
.filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`))
|
|
.filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`))
|
|
.filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap))
|
|
|
|
// Sort: prefer not-all-completed, then lowest load
|
|
eligible.sort((a, b) => {
|
|
const loadA = currentLoads.get(a) ?? 0
|
|
const loadB = currentLoads.get(b) ?? 0
|
|
const compA = completedCounts.get(a) ?? 0
|
|
const compB = completedCounts.get(b) ?? 0
|
|
const doneA = loadA > 0 && compA === loadA ? 1 : 0
|
|
const doneB = loadB > 0 && compB === loadB ? 1 : 0
|
|
if (doneA !== doneB) return doneA - doneB
|
|
return loadA - loadB
|
|
})
|
|
|
|
if (eligible.length === 0) {
|
|
failedProjects.push(assignment.project.title)
|
|
continue
|
|
}
|
|
|
|
const selectedId = eligible[0]
|
|
plannedMoves.push({
|
|
assignmentId: assignment.id, projectId: assignment.projectId,
|
|
projectTitle: assignment.project.title, newJurorId: selectedId,
|
|
juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired,
|
|
})
|
|
alreadyAssigned.add(`${selectedId}:${assignment.projectId}`)
|
|
currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1)
|
|
}
|
|
|
|
// Execute in transaction
|
|
const actualMoves: typeof plannedMoves = []
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Send MANUAL_REASSIGNED emails per destination juror
|
|
if (actualMoves.length > 0) {
|
|
const destProjectNames: Record<string, string[]> = {}
|
|
for (const move of actualMoves) {
|
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
|
}
|
|
|
|
const deadline = round.windowCloseAt
|
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt)
|
|
: undefined
|
|
|
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
|
const count = projectNames.length
|
|
await createNotification({
|
|
userId: jurorId,
|
|
type: NotificationTypes.MANUAL_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 for evaluation in ${round.name}.`
|
|
: `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' },
|
|
})
|
|
}
|
|
|
|
const sourceName = sourceJuror.name || sourceJuror.email
|
|
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
|
const topReceivers = Object.entries(destProjectNames)
|
|
.map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` })
|
|
.join(', ')
|
|
|
|
await notifyAdmins({
|
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
|
title: 'Assignment Redistribution',
|
|
message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`,
|
|
linkUrl: `/admin/rounds/${round.id}`,
|
|
linkLabel: 'View Round',
|
|
metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE',
|
|
entityType: 'Round', entityId: round.id,
|
|
detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
|
ipAddress: ctx.ip, userAgent: ctx.userAgent,
|
|
})
|
|
}
|
|
|
|
return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects }
|
|
}),
|
|
|
|
/**
|
|
* 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 },
|
|
roles: { has: '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: {
|
|
assignment: { 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
|
|
)
|
|
|
|
// Track which movable projects this candidate already has assigned
|
|
const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
|
|
alreadyAssigned.has(`${j.id}:${pid}`)
|
|
)
|
|
|
|
return {
|
|
userId: j.id,
|
|
name: j.name || j.email,
|
|
email: j.email,
|
|
currentLoad: load,
|
|
cap,
|
|
allCompleted,
|
|
eligibleProjectIds,
|
|
alreadyAssignedProjectIds,
|
|
}
|
|
})
|
|
|
|
// 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: {
|
|
assignment: { 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 with per-juror project names
|
|
if (actualMoves.length > 0) {
|
|
const destMoves: Record<string, string[]> = {}
|
|
for (const move of actualMoves) {
|
|
if (!destMoves[move.destinationJurorId]) destMoves[move.destinationJurorId] = []
|
|
destMoves[move.destinationJurorId].push(move.projectTitle)
|
|
}
|
|
|
|
for (const [jurorId, projectNames] of Object.entries(destMoves)) {
|
|
const count = projectNames.length
|
|
await createNotification({
|
|
userId: jurorId,
|
|
type: NotificationTypes.MANUAL_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 for evaluation in ${round.name}.`
|
|
: `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
|
|
linkUrl: `/jury/competitions`,
|
|
linkLabel: 'View Assignments',
|
|
metadata: { roundId: round.id, roundName: round.name, projectNames, reason: 'admin_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(destMoves)
|
|
.map(([jurorId, projects]) => {
|
|
const u = destUserMap.get(jurorId)
|
|
return `${u?.name || u?.email || jurorId} (${projects.length})`
|
|
})
|
|
.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 },
|
|
roles: { has: '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: {
|
|
assignment: { 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.
|
|
*/
|
|
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', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
|
|
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' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
|
|
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,
|
|
})
|
|
} 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}),
|
|
})
|