import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { generateAIAssignments, generateFallbackAssignments, type AssignmentProgressCallback, } from '../services/ai-assignment' import { isOpenAIConfigured } from '@/lib/openai' import { prisma } from '@/lib/prisma' import { createNotification, createBulkNotifications, notifyAdmins, NotificationTypes, } from '../services/in-app-notification' import { logAudit } from '@/server/utils/audit' 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, }, }) const config = (round.configJson ?? {}) as Record const requiredReviews = (config.requiredReviews 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 const jurors = await prisma.user.findMany({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, 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, _count: { select: { assignments: { where: { roundId } } } }, }, }) const existingAssignments = await prisma.assignment.findMany({ where: { roundId }, select: { userId: true, projectId: true }, }) // 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, projects, constraints, userId, roundId, onProgress ) // Enrich suggestions with names for storage const enrichedSuggestions = result.suggestions.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: result.suggestions.length, suggestionsJson: enrichedSuggestions, fallbackUsed: result.fallbackUsed ?? false, }, }) await notifyAdmins({ type: NotificationTypes.AI_SUGGESTIONS_READY, title: 'AI Assignment Suggestions Ready', message: `AI generated ${result.suggestions.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: result.suggestions.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 } }, }, 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, round: { status: 'ROUND_ACTIVE' }, } if (input.roundId) { where.roundId = input.roundId } if (input.status === 'pending') { where.isCompleted = false } else if (input.status === 'completed') { where.isCompleted = true } return ctx.prisma.assignment.findMany({ where, include: { project: { include: { files: true }, }, round: true, evaluation: true, }, orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }], }) }), /** * Get assignment by ID */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.id }, include: { user: { select: { id: true, name: true, email: true } }, project: { include: { files: true } }, round: { include: { evaluationForms: { where: { isActive: true } } } }, evaluation: true, }, }) // Verify access if ( ctx.user.role === 'JURY_MEMBER' && assignment.userId !== ctx.user.id ) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this assignment', }) } return assignment }), /** * Create a single assignment (admin only) */ create: adminProcedure .input( z.object({ userId: z.string(), projectId: z.string(), roundId: z.string(), isRequired: z.boolean().default(true), forceOverride: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.assignment.findUnique({ where: { userId_projectId_roundId: { userId: input.userId, projectId: input.projectId, roundId: input.roundId, }, }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'This assignment already exists', }) } const [stage, user] = await Promise.all([ ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { configJson: true }, }), ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, select: { maxAssignments: true, name: true }, }), ]) const config = (stage.configJson ?? {}) as Record 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, stageName: 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, stageName: 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.requiredReviews 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 }, }) const config = (stage.configJson ?? {}) as Record const requiredReviews = (config.requiredReviews 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 const jurors = await ctx.prisma.user.findMany({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' }, 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, stageName: 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, stageName: stage?.name, deadline, }, }) } } return { created: created.count, requested: input.assignments.length, skippedDueToCapacity, } }), /** * Start an AI assignment job (background processing) */ startAIAssignmentJob: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const existingJob = await ctx.prisma.assignmentJob.findFirst({ where: { roundId: input.roundId, status: { in: ['PENDING', 'RUNNING'] }, }, }) if (existingJob) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'An AI assignment job is already running for this stage', }) } if (!isOpenAIConfigured()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'OpenAI API is not configured', }) } const job = await ctx.prisma.assignmentJob.create({ data: { roundId: input.roundId, status: 'PENDING', }, }) runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error) return { jobId: job.id } }), /** * Get AI assignment job status (for polling) */ getAIAssignmentJobStatus: adminProcedure .input(z.object({ jobId: z.string() })) .query(async ({ ctx, input }) => { const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({ where: { id: input.jobId }, }) return { id: job.id, status: job.status, totalProjects: job.totalProjects, totalBatches: job.totalBatches, currentBatch: job.currentBatch, processedCount: job.processedCount, suggestionsCount: job.suggestionsCount, fallbackUsed: job.fallbackUsed, errorMessage: job.errorMessage, startedAt: job.startedAt, completedAt: job.completedAt, } }), /** * Get the latest AI assignment job for a round */ getLatestAIAssignmentJob: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const job = await ctx.prisma.assignmentJob.findFirst({ where: { roundId: input.roundId }, orderBy: { createdAt: 'desc' }, }) if (!job) return null return { id: job.id, status: job.status, totalProjects: job.totalProjects, totalBatches: job.totalBatches, currentBatch: job.currentBatch, processedCount: job.processedCount, suggestionsCount: job.suggestionsCount, fallbackUsed: job.fallbackUsed, errorMessage: job.errorMessage, startedAt: job.startedAt, completedAt: job.completedAt, createdAt: job.createdAt, } }), })