From 6b40fe7726ae9137ae274cdb423d0fae3381c630 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Mar 2026 12:47:06 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20tech=20debt=20batch=203=20=E2=80=94?= =?UTF-8?q?=20type=20safety=20+=20assignment=20router=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files - Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient - Fixed 4 real bugs uncovered by typing: - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole) - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter - result-lock.ts: unknown passed where Prisma.InputJsonValue required #9 — Split assignment.ts (2,775 lines) into 6 focused files: - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors - assignment-crud.ts (473 lines) — 8 core CRUD procedures - assignment-suggestions.ts (880 lines) — AI suggestions + job runner - assignment-notifications.ts (138 lines) — 2 notification procedures - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures - index.ts (15 lines) — barrel export with router merge, zero frontend changes Co-Authored-By: Claude Opus 4.6 --- src/server/routers/applicant.ts | 5 +- src/server/routers/assignment.ts | 2775 ----------------- .../routers/assignment/assignment-crud.ts | 473 +++ .../assignment/assignment-notifications.ts | 138 + .../assignment/assignment-redistribution.ts | 1162 +++++++ .../assignment/assignment-suggestions.ts | 880 ++++++ src/server/routers/assignment/index.ts | 15 + src/server/routers/assignment/shared.ts | 93 + src/server/routers/mentor.ts | 4 +- src/server/services/ai-shortlist.ts | 8 +- src/server/services/mentor-workspace.ts | 46 +- src/server/services/result-lock.ts | 14 +- src/server/services/round-assignment.ts | 10 +- src/server/services/round-engine.ts | 34 +- src/server/services/round-finalization.ts | 10 +- src/server/services/submission-manager.ts | 20 +- 16 files changed, 2836 insertions(+), 2851 deletions(-) delete mode 100644 src/server/routers/assignment.ts create mode 100644 src/server/routers/assignment/assignment-crud.ts create mode 100644 src/server/routers/assignment/assignment-notifications.ts create mode 100644 src/server/routers/assignment/assignment-redistribution.ts create mode 100644 src/server/routers/assignment/assignment-suggestions.ts create mode 100644 src/server/routers/assignment/index.ts create mode 100644 src/server/routers/assignment/shared.ts diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 9c66894..7bc8594 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine' import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs' -import type { Prisma, RoundType } from '@prisma/client' +import type { PrismaClient, Prisma, RoundType } from '@prisma/client' // All uploads use the single configured bucket (MINIO_BUCKET / mopc-files). // Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions, @@ -22,8 +22,7 @@ function generateInviteToken(): string { } /** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function isProjectRejected(prisma: any, projectId: string): Promise { +async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise { const rejected = await prisma.projectRoundState.findFirst({ where: { projectId, state: 'REJECTED' }, select: { id: true }, diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts deleted file mode 100644 index 8e20e40..0000000 --- a/src/server/routers/assignment.ts +++ /dev/null @@ -1,2775 +0,0 @@ -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 - 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 = {} - 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 = { - 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 - 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 - 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() - 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 - ) - - const deadline = stage?.windowCloseAt - ? new Date(stage.windowCloseAt).toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : undefined - - const usersByProjectCount = new Map() - 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 - 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 - 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 | 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>() - 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 - const stageMaxPerJuror = - (config.maxLoadPerJuror as number) ?? - (config.maxAssignmentsPerJuror as number) ?? - 20 - - const runningCounts = new Map() - 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 - ) - - 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() - 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 - const stageMaxPerJuror = - (config.maxLoadPerJuror as number) ?? - (config.maxAssignmentsPerJuror as number) ?? - 20 - - const runningCounts = new Map() - 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 - ) - - 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() - 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 = {} - 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() - 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 - 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() - 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() - for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1) - - const caps = new Map() - 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 = {} - 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 - 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() - 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() - 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 - 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() - 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 = {} - 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 - 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() - 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() - 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() - 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 = {} - 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 | 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 | 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 | 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 - }), -}) diff --git a/src/server/routers/assignment/assignment-crud.ts b/src/server/routers/assignment/assignment-crud.ts new file mode 100644 index 0000000..4fdcf54 --- /dev/null +++ b/src/server/routers/assignment/assignment-crud.ts @@ -0,0 +1,473 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { router, protectedProcedure, adminProcedure, userHasRole } from '../../trpc' +import { getUserAvatarUrl } from '../../utils/avatar-url' +import { createNotification, NotificationTypes } from '../../services/in-app-notification' +import { logAudit } from '@/server/utils/audit' +import { buildBatchNotifications } from './shared' + +export const assignmentCrudRouter = 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 = { + 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 + 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 + 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() + 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 + ) + + const deadline = stage?.windowCloseAt + ? new Date(stage.windowCloseAt).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : undefined + + await buildBatchNotifications(userAssignmentCounts, 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 + 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, + } + }), +}) diff --git a/src/server/routers/assignment/assignment-notifications.ts b/src/server/routers/assignment/assignment-notifications.ts new file mode 100644 index 0000000..f0b935a --- /dev/null +++ b/src/server/routers/assignment/assignment-notifications.ts @@ -0,0 +1,138 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { router, adminProcedure } from '../../trpc' +import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification' +import { logAudit } from '@/server/utils/audit' + +export const assignmentNotificationsRouter = router({ + /** + * 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 = {} + 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() + 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 } + }), +}) diff --git a/src/server/routers/assignment/assignment-redistribution.ts b/src/server/routers/assignment/assignment-redistribution.ts new file mode 100644 index 0000000..d3f4a99 --- /dev/null +++ b/src/server/routers/assignment/assignment-redistribution.ts @@ -0,0 +1,1162 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { router, adminProcedure } from '../../trpc' +import { createNotification, createBulkNotifications, notifyAdmins, NotificationTypes } from '../../services/in-app-notification' +import { logAudit } from '@/server/utils/audit' +import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../../services/juror-reassignment' +import { MOVABLE_EVAL_STATUSES, getCandidateJurors } from './shared' + +export const assignmentRedistributionRouter = router({ + 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 + 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 + const candidateJurors = await getCandidateJurors( + ctx.prisma, + input.roundId, + round.juryGroupId, + input.jurorId, + ) + + 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() + 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() + for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1) + + const caps = new Map() + 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 = {} + 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 + 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 + const candidateJurors = await getCandidateJurors( + ctx.prisma, + input.roundId, + round.juryGroupId, + input.sourceJurorId, + ) + + 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() + 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() + 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 + 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() + 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 = {} + 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 + 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 + const candidateJurors = await getCandidateJurors( + ctx.prisma, + input.roundId, + round.juryGroupId, + input.jurorId, + ) + + 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() + 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() + 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() + 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 = {} + 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 | 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 | 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 | 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 + }), +}) diff --git a/src/server/routers/assignment/assignment-suggestions.ts b/src/server/routers/assignment/assignment-suggestions.ts new file mode 100644 index 0000000..98f69da --- /dev/null +++ b/src/server/routers/assignment/assignment-suggestions.ts @@ -0,0 +1,880 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { router, adminProcedure, withAIRateLimit } from '../../trpc' +import { + generateAIAssignments, + type AssignmentProgressCallback, +} from '../../services/ai-assignment' +import { isOpenAIConfigured } from '@/lib/openai' +import { prisma } from '@/lib/prisma' +import { notifyAdmins, NotificationTypes } from '../../services/in-app-notification' +import { logAudit } from '@/server/utils/audit' +import { buildBatchNotifications } from './shared' + +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 + 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 = {} + 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 assignmentSuggestionsRouter = router({ + /** + * 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 + 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 | 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>() + 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 + const stageMaxPerJuror = + (config.maxLoadPerJuror as number) ?? + (config.maxAssignmentsPerJuror as number) ?? + 20 + + const runningCounts = new Map() + 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 + ) + + 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 + + await buildBatchNotifications(userAssignmentCounts, 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 + const stageMaxPerJuror = + (config.maxLoadPerJuror as number) ?? + (config.maxAssignmentsPerJuror as number) ?? + 20 + + const runningCounts = new Map() + 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 + ) + + 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 + + await buildBatchNotifications(userAssignmentCounts, 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, + } + }), +}) diff --git a/src/server/routers/assignment/index.ts b/src/server/routers/assignment/index.ts new file mode 100644 index 0000000..d279d66 --- /dev/null +++ b/src/server/routers/assignment/index.ts @@ -0,0 +1,15 @@ +import { router } from '../../trpc' +import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../../services/juror-reassignment' +import { assignmentCrudRouter } from './assignment-crud' +import { assignmentSuggestionsRouter } from './assignment-suggestions' +import { assignmentNotificationsRouter } from './assignment-notifications' +import { assignmentRedistributionRouter } from './assignment-redistribution' + +export { reassignAfterCOI, reassignDroppedJurorAssignments } + +export const assignmentRouter = router({ + ...assignmentCrudRouter._def.procedures, + ...assignmentSuggestionsRouter._def.procedures, + ...assignmentNotificationsRouter._def.procedures, + ...assignmentRedistributionRouter._def.procedures, +}) diff --git a/src/server/routers/assignment/shared.ts b/src/server/routers/assignment/shared.ts new file mode 100644 index 0000000..296959b --- /dev/null +++ b/src/server/routers/assignment/shared.ts @@ -0,0 +1,93 @@ +import type { PrismaClient } from '@prisma/client' +import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification' + +/** Evaluation statuses that are safe to move (not yet finalized). */ +export const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const + +/** + * Groups a per-user assignment count map into batches by count, then sends + * BATCH_ASSIGNED notifications via createBulkNotifications. + * + * @param userAssignmentCounts - map of userId → number of newly-assigned projects + * @param stageName - display name of the round (for the notification message) + * @param deadline - formatted deadline string (optional) + */ +export async function buildBatchNotifications( + userAssignmentCounts: Record, + stageName: string | null | undefined, + deadline: string | undefined, +): Promise { + const usersByProjectCount = new Map() + 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 ${stageName || 'this stage'}.`, + linkUrl: `/jury/competitions`, + linkLabel: 'View Assignments', + metadata: { + projectCount, + roundName: stageName, + deadline, + }, + }) + } +} + +export type CandidateJuror = { + id: string + name: string | null + email: string + maxAssignments: number | null +} + +/** + * Builds the candidate juror pool for a round, scoped to the jury group if one + * is assigned, otherwise falling back to all active JURY_MEMBER users who have + * at least one assignment in the round. + * + * @param prisma - Prisma client (or transaction client) + * @param roundId - round being processed + * @param juryGroupId - optional jury group id from the round + * @param excludeUserId - userId to exclude from results (the source / dropped juror) + */ +export async function getCandidateJurors( + prisma: PrismaClient, + roundId: string, + juryGroupId: string | null | undefined, + excludeUserId: string, +): Promise { + if (juryGroupId) { + const members = await prisma.juryGroupMember.findMany({ + where: { juryGroupId }, + include: { + user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } }, + }, + }) + return members + .filter((m) => m.user.status === 'ACTIVE' && m.user.id !== excludeUserId) + .map((m) => m.user) + } + + const roundJurorIds = await prisma.assignment.findMany({ + where: { roundId }, + select: { userId: true }, + distinct: ['userId'], + }) + const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== excludeUserId) + + if (ids.length === 0) return [] + + return prisma.user.findMany({ + where: { id: { in: ids }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' }, + select: { id: true, name: true, email: true, maxAssignments: true }, + }) +} diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 9cfca8d..440ccda 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1343,7 +1343,7 @@ export const mentorRouter = router({ .mutation(async ({ ctx, input }) => { return workspaceSendMessage( { - mentorAssignmentId: input.mentorAssignmentId, + workspaceId: input.mentorAssignmentId, senderId: ctx.user.id, message: input.message, role: input.role, @@ -1389,7 +1389,7 @@ export const mentorRouter = router({ .mutation(async ({ ctx, input }) => { return workspaceUploadFile( { - mentorAssignmentId: input.mentorAssignmentId, + workspaceId: input.mentorAssignmentId, uploadedByUserId: ctx.user.id, fileName: input.fileName, mimeType: input.mimeType, diff --git a/src/server/services/ai-shortlist.ts b/src/server/services/ai-shortlist.ts index 10f1c96..7ba390f 100644 --- a/src/server/services/ai-shortlist.ts +++ b/src/server/services/ai-shortlist.ts @@ -14,7 +14,7 @@ import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/open import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage' import { classifyAIError, logAIError } from './ai-errors' import { extractMultipleFileContents } from './file-content-extractor' -import type { PrismaClient } from '@prisma/client' +import type { PrismaClient, CompetitionCategory } from '@prisma/client' // ─── Types ────────────────────────────────────────────────────────────────── @@ -95,14 +95,14 @@ async function generateCategoryShortlist( rubric?: string aiParseFiles: boolean }, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> { const { roundId, category, topN, rubric, aiParseFiles } = params // Load projects with evaluations for this category const projects = await prisma.project.findMany({ where: { - competitionCategory: category, + competitionCategory: category as CompetitionCategory, assignments: { some: { roundId } }, }, include: { @@ -320,7 +320,7 @@ export async function generateShortlist( rubric?: string aiParseFiles?: boolean }, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const { roundId, diff --git a/src/server/services/mentor-workspace.ts b/src/server/services/mentor-workspace.ts index 520153d..8f61f16 100644 --- a/src/server/services/mentor-workspace.ts +++ b/src/server/services/mentor-workspace.ts @@ -19,13 +19,13 @@ type WorkspaceResult = { success: boolean; errors?: string[] } * Activate a mentor workspace for a given assignment. */ export async function activateWorkspace( - mentorAssignmentId: string, + workspaceId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const assignment = await prisma.mentorAssignment.findUnique({ - where: { id: mentorAssignmentId }, + where: { id: workspaceId }, }) if (!assignment) { @@ -36,9 +36,9 @@ export async function activateWorkspace( return { success: false, errors: ['Workspace is already enabled'] } } - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.mentorAssignment.update({ - where: { id: mentorAssignmentId }, + where: { id: workspaceId }, data: { workspaceEnabled: true, workspaceOpenAt: new Date(), @@ -49,7 +49,7 @@ export async function activateWorkspace( data: { eventType: 'mentor_workspace.activated', entityType: 'MentorAssignment', - entityId: mentorAssignmentId, + entityId: workspaceId, actorId, detailsJson: { projectId: assignment.projectId, @@ -64,7 +64,7 @@ export async function activateWorkspace( userId: actorId, action: 'WORKSPACE_ACTIVATE', entityType: 'MentorAssignment', - entityId: mentorAssignmentId, + entityId: workspaceId, detailsJson: { projectId: assignment.projectId }, }) }) @@ -86,15 +86,15 @@ export async function activateWorkspace( */ export async function sendMessage( params: { - mentorAssignmentId: string + workspaceId: string senderId: string message: string role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE' }, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { const assignment = await prisma.mentorAssignment.findUnique({ - where: { id: params.mentorAssignmentId }, + where: { id: params.workspaceId }, }) if (!assignment) { @@ -107,11 +107,11 @@ export async function sendMessage( return prisma.mentorMessage.create({ data: { - mentorAssignmentId: params.mentorAssignmentId, + workspaceId: params.workspaceId, projectId: assignment.projectId, senderId: params.senderId, message: params.message, - role: params.role, + senderRole: params.role, }, include: { sender: { select: { id: true, name: true, email: true } }, @@ -123,11 +123,11 @@ export async function sendMessage( * Get messages for a workspace. */ export async function getMessages( - mentorAssignmentId: string, - prisma: PrismaClient | any, + workspaceId: string, + prisma: PrismaClient, ) { return prisma.mentorMessage.findMany({ - where: { mentorAssignmentId }, + where: { workspaceId }, include: { sender: { select: { id: true, name: true, email: true, role: true } }, }, @@ -140,7 +140,7 @@ export async function getMessages( */ export async function markRead( messageId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { await prisma.mentorMessage.update({ where: { id: messageId }, @@ -155,7 +155,7 @@ export async function markRead( */ export async function uploadFile( params: { - mentorAssignmentId: string + workspaceId: string uploadedByUserId: string fileName: string mimeType: string @@ -164,10 +164,10 @@ export async function uploadFile( objectKey: string description?: string }, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { const assignment = await prisma.mentorAssignment.findUnique({ - where: { id: params.mentorAssignmentId }, + where: { id: params.workspaceId }, }) if (!assignment) { @@ -180,7 +180,7 @@ export async function uploadFile( return prisma.mentorFile.create({ data: { - mentorAssignmentId: params.mentorAssignmentId, + mentorAssignmentId: params.workspaceId, uploadedByUserId: params.uploadedByUserId, fileName: params.fileName, mimeType: params.mimeType, @@ -205,7 +205,7 @@ export async function addFileComment( content: string parentCommentId?: string }, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { return prisma.mentorFileComment.create({ data: { @@ -233,7 +233,7 @@ export async function promoteFile( slotKey: string promotedById: string }, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ success: boolean; errors?: string[] }> { try { const file = await prisma.mentorFile.findUnique({ @@ -251,7 +251,7 @@ export async function promoteFile( return { success: false, errors: ['File is already promoted'] } } - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Mark file as promoted await tx.mentorFile.update({ where: { id: params.mentorFileId }, diff --git a/src/server/services/result-lock.ts b/src/server/services/result-lock.ts index 2bc694b..cb3e3e6 100644 --- a/src/server/services/result-lock.ts +++ b/src/server/services/result-lock.ts @@ -46,7 +46,7 @@ export async function lockResults( lockedById: string resultSnapshot: unknown }, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { // Validate deliberation is finalized @@ -84,7 +84,7 @@ export async function lockResults( } } - const lock = await prisma.$transaction(async (tx: any) => { + const lock = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const created = await tx.resultLock.create({ data: { competitionId: params.competitionId, @@ -109,7 +109,7 @@ export async function lockResults( snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'result-lock', - resultSnapshot: params.resultSnapshot, + resultSnapshot: params.resultSnapshot as Prisma.InputJsonValue, }, }, }) @@ -155,7 +155,7 @@ export async function unlockResults( unlockedById: string reason: string }, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const lock = await prisma.resultLock.findUnique({ @@ -166,7 +166,7 @@ export async function unlockResults( return { success: false, errors: ['Result lock not found'] } } - const event = await prisma.$transaction(async (tx: any) => { + const event = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const created = await tx.resultUnlockEvent.create({ data: { resultLockId: params.resultLockId, @@ -226,7 +226,7 @@ export async function isLocked( competitionId: string, roundId: string, category: CompetitionCategory, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const lock = await prisma.resultLock.findFirst({ where: { competitionId, roundId, category }, @@ -265,7 +265,7 @@ export async function isLocked( */ export async function getLockHistory( competitionId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { return prisma.resultLock.findMany({ where: { competitionId }, diff --git a/src/server/services/round-assignment.ts b/src/server/services/round-assignment.ts index d094341..3e25b7b 100644 --- a/src/server/services/round-assignment.ts +++ b/src/server/services/round-assignment.ts @@ -75,7 +75,7 @@ const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10 export async function previewRoundAssignment( roundId: string, config?: { honorIntents?: boolean; requiredReviews?: number }, - prisma?: PrismaClient | any, + prisma?: PrismaClient, ): Promise { const db = prisma ?? (await import('@/lib/prisma')).prisma const honorIntents = config?.honorIntents ?? true @@ -390,7 +390,7 @@ export async function executeRoundAssignment( roundId: string, assignments: Array<{ userId: string; projectId: string }>, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ created: number; errors: string[] }> { const db = prisma ?? (await import('@/lib/prisma')).prisma const errors: string[] = [] @@ -398,7 +398,7 @@ export async function executeRoundAssignment( for (const assignment of assignments) { try { - await db.$transaction(async (tx: any) => { + await db.$transaction(async (tx: Prisma.TransactionClient) => { // Create assignment record await tx.assignment.create({ data: { @@ -483,7 +483,7 @@ export async function executeRoundAssignment( export async function getRoundCoverageReport( roundId: string, requiredReviews: number = 3, - prisma?: PrismaClient | any, + prisma?: PrismaClient, ): Promise { const db = prisma ?? (await import('@/lib/prisma')).prisma @@ -558,7 +558,7 @@ export async function getRoundCoverageReport( export async function getUnassignedQueue( roundId: string, requiredReviews: number = 3, - prisma?: PrismaClient | any, + prisma?: PrismaClient, ) { const db = prisma ?? (await import('@/lib/prisma')).prisma diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 57377b9..8cb7f58 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -74,7 +74,7 @@ const VALID_PROJECT_TRANSITIONS: Record = { export async function activateRound( roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const round = await prisma.round.findUnique({ @@ -127,7 +127,7 @@ export async function activateRound( windowData.windowOpenAt = now } - const updated = await prisma.$transaction(async (tx: any) => { + const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ACTIVE', ...windowData }, @@ -234,7 +234,7 @@ export async function activateRound( export async function closeRound( roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const round = await prisma.round.findUnique({ @@ -267,7 +267,7 @@ export async function closeRound( } } - const updated = await prisma.$transaction(async (tx: any) => { + const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_CLOSED' }, @@ -383,7 +383,7 @@ export async function closeRound( export async function archiveRound( roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const round = await prisma.round.findUnique({ where: { id: roundId } }) @@ -399,7 +399,7 @@ export async function archiveRound( } } - const updated = await prisma.$transaction(async (tx: any) => { + const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const result = await tx.round.update({ where: { id: roundId }, data: { status: 'ROUND_ARCHIVED' }, @@ -456,7 +456,7 @@ export async function archiveRound( export async function reopenRound( roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const round = await prisma.round.findUnique({ @@ -475,7 +475,7 @@ export async function reopenRound( } } - const result = await prisma.$transaction(async (tx: any) => { + const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Pause any subsequent active rounds in the same competition const subsequentActiveRounds = await tx.round.findMany({ where: { @@ -601,7 +601,7 @@ export async function transitionProject( roundId: string, newState: ProjectRoundStateValue, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, options?: { adminOverride?: boolean }, ): Promise { try { @@ -624,7 +624,7 @@ export async function transitionProject( return { success: false, errors: [`Project ${projectId} not found`] } } - const result = await prisma.$transaction(async (tx: any) => { + const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const now = new Date() // Upsert ProjectRoundState @@ -722,7 +722,7 @@ export async function batchTransitionProjects( roundId: string, newState: ProjectRoundStateValue, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, options?: { adminOverride?: boolean }, ): Promise { const succeeded: string[] = [] @@ -754,7 +754,7 @@ export async function batchTransitionProjects( export async function getProjectRoundStates( roundId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { const states = await prisma.projectRoundState.findMany({ where: { roundId }, @@ -803,7 +803,7 @@ export async function getProjectRoundStates( export async function getProjectRoundState( projectId: string, roundId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { return prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, @@ -823,7 +823,7 @@ export async function checkRequirementsAndTransition( projectId: string, roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ transitioned: boolean; newState?: string }> { try { // Get all required FileRequirements for this round (legacy model) @@ -939,7 +939,7 @@ export async function batchCheckRequirementsAndTransition( roundId: string, projectIds: string[], actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ transitionedCount: number; projectIds: string[] }> { if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] } @@ -1051,7 +1051,7 @@ export async function triggerInProgressOnActivity( projectId: string, roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const prs = await prisma.projectRoundState.findUnique({ @@ -1078,7 +1078,7 @@ export async function checkEvaluationCompletionAndTransition( projectId: string, roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ transitioned: boolean }> { try { const prs = await prisma.projectRoundState.findUnique({ diff --git a/src/server/services/round-finalization.ts b/src/server/services/round-finalization.ts index bd2496e..447c1d9 100644 --- a/src/server/services/round-finalization.ts +++ b/src/server/services/round-finalization.ts @@ -75,7 +75,7 @@ export type ConfirmFinalizationResult = { export async function processRoundClose( roundId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise<{ processed: number }> { const round = await prisma.round.findUnique({ where: { id: roundId }, @@ -305,7 +305,7 @@ export async function processRoundClose( const now = new Date() if (transitionUpdates.length > 0) { - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Step through intermediate states in bulk // PENDING → IN_PROGRESS for projects going to COMPLETED const pendingToCompleted = transitionUpdates.filter( @@ -402,7 +402,7 @@ export async function processRoundClose( export async function getFinalizationSummary( roundId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, @@ -568,7 +568,7 @@ export async function confirmFinalization( rejectionMessage?: string }, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { // Validate: round is CLOSED, not already finalized, grace period expired const round = await prisma.round.findUniqueOrThrow({ @@ -612,7 +612,7 @@ export async function confirmFinalization( : 'Next Round' // Execute finalization in a transaction - const result = await prisma.$transaction(async (tx: any) => { + const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const projectStates = await tx.projectRoundState.findMany({ where: { roundId, proposedOutcome: { not: null } }, include: { diff --git a/src/server/services/submission-manager.ts b/src/server/services/submission-manager.ts index fef3da7..a8b920d 100644 --- a/src/server/services/submission-manager.ts +++ b/src/server/services/submission-manager.ts @@ -34,7 +34,7 @@ export type SubmissionValidationResult = { export async function openWindow( windowId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) @@ -47,7 +47,7 @@ export async function openWindow( return { success: false, errors: ['Cannot open a locked window'] } } - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.submissionWindow.update({ where: { id: windowId }, data: { @@ -93,7 +93,7 @@ export async function openWindow( export async function closeWindow( windowId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) @@ -102,7 +102,7 @@ export async function closeWindow( return { success: false, errors: [`Submission window ${windowId} not found`] } } - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { const data: Record = { windowCloseAt: new Date(), } @@ -155,7 +155,7 @@ export async function closeWindow( export async function lockWindow( windowId: string, actorId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) @@ -168,7 +168,7 @@ export async function lockWindow( return { success: false, errors: ['Window is already locked'] } } - await prisma.$transaction(async (tx: any) => { + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.submissionWindow.update({ where: { id: windowId }, data: { isLocked: true }, @@ -212,7 +212,7 @@ export async function lockWindow( */ export async function checkDeadlinePolicy( windowId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) @@ -273,7 +273,7 @@ export async function validateSubmission( projectId: string, windowId: string, files: Array<{ mimeType: string; size: number; requirementId?: string }>, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const errors: string[] = [] @@ -327,7 +327,7 @@ export async function validateSubmission( */ export async function isWindowReadOnly( windowId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ): Promise { const status = await checkDeadlinePolicy(windowId, prisma) return status.status === 'LOCKED' || status.status === 'CLOSED' @@ -340,7 +340,7 @@ export async function isWindowReadOnly( */ export async function getVisibleWindows( roundId: string, - prisma: PrismaClient | any, + prisma: PrismaClient, ) { const visibility = await prisma.roundSubmissionVisibility.findMany({ where: { roundId, canView: true },