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, } }), })