import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { generateAIAssignments, generateFallbackAssignments, } from '../services/ai-assignment' import { isOpenAIConfigured } from '@/lib/openai' export const assignmentRouter = router({ /** * List assignments for a round (admin only) */ listByRound: 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: '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), }) ) .mutation(async ({ ctx, input }) => { // Check if assignment already exists 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', }) } // Check user's assignment limit const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, select: { maxAssignments: true }, }) if (user.maxAssignments !== null) { const currentCount = await ctx.prisma.assignment.count({ where: { userId: input.userId, roundId: input.roundId }, }) if (currentCount >= user.maxAssignments) { throw new TRPCError({ code: 'BAD_REQUEST', message: `User has reached their maximum assignment limit of ${user.maxAssignments}`, }) } } const assignment = await ctx.prisma.assignment.create({ data: { ...input, method: 'MANUAL', createdBy: ctx.user.id, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'Assignment', entityId: assignment.id, detailsJson: input, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return assignment }), /** * Bulk create assignments (admin only) */ bulkCreate: adminProcedure .input( z.object({ assignments: z.array( z.object({ userId: z.string(), projectId: z.string(), roundId: z.string(), }) ), }) ) .mutation(async ({ ctx, input }) => { const result = await ctx.prisma.assignment.createMany({ data: input.assignments.map((a) => ({ ...a, method: 'BULK', createdBy: ctx.user.id, })), skipDuplicates: true, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'BULK_CREATE', entityType: 'Assignment', detailsJson: { count: result.count }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { created: result.count } }), /** * 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 ctx.prisma.auditLog.create({ data: { 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 [ 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: { roundId: input.roundId }, select: { id: true, title: true, _count: { select: { assignments: true } }, }, }), ]) const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { requiredReviews: true }, }) const projectsWithFullCoverage = projectCoverage.filter( (p) => p._count.assignments >= round.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(), maxPerJuror: z.number().int().min(1).max(50).default(10), minPerProject: z.number().int().min(1).max(10).default(3), }) ) .query(async ({ ctx, input }) => { // Get all active jury members with their expertise and current load 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 } }, }, }, }, }) // Get all projects that need more assignments const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId }, select: { id: true, title: true, tags: true, _count: { select: { assignments: true } }, }, }) // Get existing assignments to avoid duplicates 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}`) ) // Simple scoring algorithm const suggestions: Array<{ userId: string projectId: string score: number reasoning: string[] }> = [] for (const project of projects) { // Skip if project has enough assignments if (project._count.assignments >= input.minPerProject) continue const neededAssignments = input.minPerProject - project._count.assignments // Score each juror for this project const jurorScores = jurors .filter((j) => { // Skip if already assigned if (assignmentSet.has(`${j.id}-${project.id}`)) return false // Skip if at max capacity const maxAllowed = j.maxAssignments ?? input.maxPerJuror if (j._count.assignments >= maxAllowed) return false return true }) .map((juror) => { const reasoning: string[] = [] let score = 0 // Expertise match (40% weight) const matchingTags = juror.expertiseTags.filter((tag) => project.tags.includes(tag) ) const expertiseScore = matchingTags.length > 0 ? matchingTags.length / Math.max(project.tags.length, 1) : 0 score += expertiseScore * 40 if (matchingTags.length > 0) { reasoning.push(`Expertise match: ${matchingTags.join(', ')}`) } // Load balancing (25% weight) const maxAllowed = juror.maxAssignments ?? input.maxPerJuror const loadScore = 1 - juror._count.assignments / maxAllowed score += loadScore * 25 reasoning.push( `Workload: ${juror._count.assignments}/${maxAllowed} assigned` ) return { userId: juror.id, projectId: project.id, score, reasoning, } }) .sort((a, b) => b.score - a.score) .slice(0, neededAssignments) suggestions.push(...jurorScores) } // Sort by score and return 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 */ getAISuggestions: adminProcedure .input( z.object({ roundId: z.string(), useAI: z.boolean().default(true), maxPerJuror: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { // Get round info const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { requiredReviews: true }, }) // Get all active jury members with their expertise and current load 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 } }, }, }, }, }) // Get all projects in the round const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId }, select: { id: true, title: true, description: true, tags: true, teamName: true, _count: { select: { assignments: true } }, }, }) // Get existing assignments const existingAssignments = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, select: { userId: true, projectId: true }, }) const constraints = { requiredReviewsPerProject: round.requiredReviews, maxAssignmentsPerJuror: input.maxPerJuror, existingAssignments: existingAssignments.map((a) => ({ jurorId: a.userId, projectId: a.projectId, })), } // Use AI or fallback based on input and availability let result if (input.useAI) { result = await generateAIAssignments(jurors, projects, constraints) } else { result = generateFallbackAssignments(jurors, projects, constraints) } // Enrich suggestions with user and project names for display const enrichedSuggestions = await Promise.all( result.suggestions.map(async (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', } }) ) return { success: result.success, suggestions: enrichedSuggestions, fallbackUsed: result.fallbackUsed, error: result.error, } }), /** * 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), }) ) .mutation(async ({ ctx, input }) => { const created = await ctx.prisma.assignment.createMany({ data: input.assignments.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, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS', entityType: 'Assignment', detailsJson: { roundId: input.roundId, count: created.count, usedAI: input.usedAI, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { created: created.count } }), /** * 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(), }) ), }) ) .mutation(async ({ ctx, input }) => { const created = await ctx.prisma.assignment.createMany({ data: input.assignments.map((a) => ({ userId: a.userId, projectId: a.projectId, roundId: input.roundId, method: 'ALGORITHM', aiReasoning: a.reasoning, createdBy: ctx.user.id, })), skipDuplicates: true, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'APPLY_SUGGESTIONS', entityType: 'Assignment', detailsJson: { roundId: input.roundId, count: created.count, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { created: created.count } }), })