import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { notifyRoundJury, NotificationTypes, } from '../services/in-app-notification' export const roundRouter = router({ /** * List rounds for a program */ list: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.round.findMany({ where: { programId: input.programId }, orderBy: { sortOrder: 'asc' }, include: { _count: { select: { projects: true, assignments: true }, }, }, }) }), /** * Get a single round with stats */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, include: { program: true, _count: { select: { projects: true, assignments: true }, }, evaluationForms: { where: { isActive: true }, take: 1, }, }, }) // Get evaluation stats const evaluationStats = await ctx.prisma.evaluation.groupBy({ by: ['status'], where: { assignment: { roundId: input.id }, }, _count: true, }) return { ...round, evaluationStats, } }), /** * Create a new round (admin only) */ create: adminProcedure .input( z.object({ programId: z.string(), name: z.string().min(1).max(255), roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'), requiredReviews: z.number().int().min(1).max(10).default(3), minAssignmentsPerJuror: z.number().int().min(1).max(50).default(5), maxAssignmentsPerJuror: z.number().int().min(1).max(100).default(20), sortOrder: z.number().int().optional(), settingsJson: z.record(z.unknown()).optional(), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), entryNotificationType: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Validate assignment constraints if (input.minAssignmentsPerJuror > input.maxAssignmentsPerJuror) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Min assignments per juror must be less than or equal to max', }) } // Validate dates if (input.votingStartAt && input.votingEndAt) { if (input.votingEndAt <= input.votingStartAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'End date must be after start date', }) } } // Auto-set sortOrder if not provided (append to end) let sortOrder = input.sortOrder if (sortOrder === undefined) { const maxOrder = await ctx.prisma.round.aggregate({ where: { programId: input.programId }, _max: { sortOrder: true }, }) sortOrder = (maxOrder._max.sortOrder ?? -1) + 1 } const { settingsJson, sortOrder: _so, ...rest } = input // Auto-activate if voting start date is in the past const now = new Date() const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now const round = await ctx.prisma.round.create({ data: { ...rest, sortOrder, status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT', settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, }, }) // For FILTERING rounds, automatically move all projects from the program to this round if (input.roundType === 'FILTERING') { await ctx.prisma.project.updateMany({ where: { round: { programId: input.programId }, roundId: { not: round.id }, }, data: { roundId: round.id, status: 'SUBMITTED', }, }) } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'Round', entityId: round.id, detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return round }), /** * Update round details (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional().nullable(), roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).optional(), requiredReviews: z.number().int().min(1).max(10).optional(), minAssignmentsPerJuror: z.number().int().min(1).max(50).optional(), maxAssignmentsPerJuror: z.number().int().min(1).max(100).optional(), submissionDeadline: z.date().optional().nullable(), votingStartAt: z.date().optional().nullable(), votingEndAt: z.date().optional().nullable(), settingsJson: z.record(z.unknown()).optional(), entryNotificationType: z.string().optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { id, settingsJson, ...data } = input // Validate dates if both provided if (data.votingStartAt && data.votingEndAt) { if (data.votingEndAt <= data.votingStartAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'End date must be after start date', }) } } // Validate assignment constraints if either is provided if (data.minAssignmentsPerJuror !== undefined || data.maxAssignmentsPerJuror !== undefined) { const existingRound = await ctx.prisma.round.findUnique({ where: { id }, select: { minAssignmentsPerJuror: true, maxAssignmentsPerJuror: true, status: true }, }) const newMin = data.minAssignmentsPerJuror ?? existingRound?.minAssignmentsPerJuror ?? 5 const newMax = data.maxAssignmentsPerJuror ?? existingRound?.maxAssignmentsPerJuror ?? 20 if (newMin > newMax) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Min assignments per juror must be less than or equal to max', }) } } // Check if we should auto-activate (if voting start is in the past and round is DRAFT) const now = new Date() let autoActivate = false if (data.votingStartAt && data.votingStartAt <= now) { const existingRound = await ctx.prisma.round.findUnique({ where: { id }, select: { status: true }, }) if (existingRound?.status === 'DRAFT') { autoActivate = true } } const round = await ctx.prisma.round.update({ where: { id }, data: { ...data, ...(autoActivate && { status: 'ACTIVE' }), settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'Round', entityId: id, detailsJson: { ...data, settingsJson } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return round }), /** * Update round status (admin only) */ updateStatus: adminProcedure .input( z.object({ id: z.string(), status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']), }) ) .mutation(async ({ ctx, input }) => { // Get previous status and voting dates for audit const previousRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, select: { status: true, votingStartAt: true, votingEndAt: true }, }) const now = new Date() // When activating a round, if votingStartAt is in the future, update it to now // This ensures voting actually starts when the admin opens the round let votingStartAtUpdated = false const updateData: Parameters[0]['data'] = { status: input.status, } if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') { if (previousRound.votingStartAt && previousRound.votingStartAt > now) { updateData.votingStartAt = now votingStartAtUpdated = true } } const round = await ctx.prisma.round.update({ where: { id: input.id }, data: updateData, }) // Map status to specific action name const statusActionMap: Record = { ACTIVE: 'ROUND_ACTIVATED', CLOSED: 'ROUND_CLOSED', ARCHIVED: 'ROUND_ARCHIVED', } const action = statusActionMap[input.status] || 'UPDATE_STATUS' // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action, entityType: 'Round', entityId: input.id, detailsJson: { status: input.status, previousStatus: previousRound.status, ...(votingStartAtUpdated && { votingStartAtUpdated: true, previousVotingStartAt: previousRound.votingStartAt, newVotingStartAt: now, }), }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) // Notify jury members when round is activated if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') { // Get round details and assignment counts per user const roundDetails = await ctx.prisma.round.findUnique({ where: { id: input.id }, include: { _count: { select: { assignments: true } }, }, }) // Get count of distinct jury members assigned const juryCount = await ctx.prisma.assignment.groupBy({ by: ['userId'], where: { roundId: input.id }, _count: true, }) if (roundDetails && juryCount.length > 0) { const deadline = roundDetails.votingEndAt ? new Date(roundDetails.votingEndAt).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }) : undefined // Notify all jury members with assignments in this round await notifyRoundJury(input.id, { type: NotificationTypes.ROUND_NOW_OPEN, title: `${roundDetails.name} is Now Open`, message: `The evaluation round is now open. Please review your assigned projects and submit your evaluations before the deadline.`, linkUrl: `/jury/assignments`, linkLabel: 'Start Evaluating', priority: 'high', metadata: { roundName: roundDetails.name, projectCount: roundDetails._count.assignments, deadline, }, }) } } return round }), /** * Check if voting is currently open for a round */ isVotingOpen: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, }) const now = new Date() const isOpen = round.status === 'ACTIVE' && round.votingStartAt !== null && round.votingEndAt !== null && now >= round.votingStartAt && now <= round.votingEndAt return { isOpen, startsAt: round.votingStartAt, endsAt: round.votingEndAt, status: round.status, } }), /** * Get round progress statistics */ getProgress: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const [totalProjects, totalAssignments, completedAssignments] = await Promise.all([ ctx.prisma.project.count({ where: { roundId: input.id } }), ctx.prisma.assignment.count({ where: { roundId: input.id } }), ctx.prisma.assignment.count({ where: { roundId: input.id, isCompleted: true }, }), ]) const evaluationsByStatus = await ctx.prisma.evaluation.groupBy({ by: ['status'], where: { assignment: { roundId: input.id }, }, _count: true, }) return { totalProjects, totalAssignments, completedAssignments, completionPercentage: totalAssignments > 0 ? Math.round((completedAssignments / totalAssignments) * 100) : 0, evaluationsByStatus: evaluationsByStatus.reduce( (acc, curr) => { acc[curr.status] = curr._count return acc }, {} as Record ), } }), /** * Update or create evaluation form for a round (admin only) */ updateEvaluationForm: adminProcedure .input( z.object({ roundId: z.string(), criteria: z.array( z.object({ id: z.string(), label: z.string().min(1), description: z.string().optional(), scale: z.number().int().min(1).max(10), weight: z.number().optional(), required: z.boolean(), }) ), }) ) .mutation(async ({ ctx, input }) => { const { roundId, criteria } = input // Check if there are existing evaluations const existingEvaluations = await ctx.prisma.evaluation.count({ where: { assignment: { roundId }, status: { in: ['SUBMITTED', 'LOCKED'] }, }, }) if (existingEvaluations > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot modify criteria after evaluations have been submitted', }) } // Get or create the active evaluation form const existingForm = await ctx.prisma.evaluationForm.findFirst({ where: { roundId, isActive: true }, }) let form if (existingForm) { // Update existing form form = await ctx.prisma.evaluationForm.update({ where: { id: existingForm.id }, data: { criteriaJson: criteria }, }) } else { // Create new form form = await ctx.prisma.evaluationForm.create({ data: { roundId, criteriaJson: criteria, isActive: true, }, }) } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_EVALUATION_FORM', entityType: 'EvaluationForm', entityId: form.id, detailsJson: { roundId, criteriaCount: criteria.length }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return form }), /** * Get evaluation form for a round */ getEvaluationForm: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.evaluationForm.findFirst({ where: { roundId: input.roundId, isActive: true }, }) }), /** * Delete a round (admin only) * Cascades to projects, assignments, evaluations, etc. */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, include: { _count: { select: { projects: true, assignments: true } }, }, }) await ctx.prisma.round.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'Round', entityId: input.id, detailsJson: { name: round.name, status: round.status, projectsDeleted: round._count.projects, assignmentsDeleted: round._count.assignments, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return round }), /** * Check if a round has any submitted evaluations */ hasEvaluations: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const count = await ctx.prisma.evaluation.count({ where: { assignment: { roundId: input.roundId }, status: { in: ['SUBMITTED', 'LOCKED'] }, }, }) return count > 0 }), /** * Assign projects from the program pool to a round */ assignProjects: adminProcedure .input( z.object({ roundId: z.string(), projectIds: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { // Verify round exists and get programId const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) // Update projects to assign them to this round const updated = await ctx.prisma.project.updateMany({ where: { id: { in: input.projectIds }, round: { programId: round.programId }, }, data: { roundId: input.roundId, status: 'SUBMITTED', }, }) if (updated.count === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No projects were assigned. Projects may not belong to this program.', }) } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'ASSIGN_PROJECTS_TO_ROUND', entityType: 'Round', entityId: input.roundId, detailsJson: { projectCount: updated.count }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { assigned: updated.count } }), /** * Remove projects from a round */ removeProjects: adminProcedure .input( z.object({ roundId: z.string(), projectIds: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { // Set roundId to null for these projects (remove from round) const updated = await ctx.prisma.project.updateMany({ where: { roundId: input.roundId, id: { in: input.projectIds }, }, data: { roundId: null as unknown as string, // Projects need to be orphaned }, }) const deleted = { count: updated.count } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'REMOVE_PROJECTS_FROM_ROUND', entityType: 'Round', entityId: input.roundId, detailsJson: { projectCount: deleted.count }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { removed: deleted.count } }), /** * Advance projects from one round to the next * Creates new RoundProject entries in the target round (keeps them in source round too) */ advanceProjects: adminProcedure .input( z.object({ fromRoundId: z.string(), toRoundId: z.string(), projectIds: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { // Verify both rounds exist and belong to the same program const [fromRound, toRound] = await Promise.all([ ctx.prisma.round.findUniqueOrThrow({ where: { id: input.fromRoundId } }), ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }), ]) if (fromRound.programId !== toRound.programId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Rounds must belong to the same program', }) } // Verify all projects are in the source round const sourceProjects = await ctx.prisma.project.findMany({ where: { roundId: input.fromRoundId, id: { in: input.projectIds }, }, select: { id: true }, }) if (sourceProjects.length !== input.projectIds.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Some projects are not in the source round', }) } // Move projects to target round const updated = await ctx.prisma.project.updateMany({ where: { id: { in: input.projectIds }, roundId: input.fromRoundId, }, data: { roundId: input.toRoundId, status: 'SUBMITTED', }, }) const created = { count: updated.count } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'ADVANCE_PROJECTS', entityType: 'Round', entityId: input.toRoundId, detailsJson: { fromRoundId: input.fromRoundId, toRoundId: input.toRoundId, projectCount: created.count, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { advanced: created.count } }), /** * Reorder rounds within a program */ reorder: adminProcedure .input( z.object({ programId: z.string(), roundIds: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { // Update sortOrder for each round based on array position await ctx.prisma.$transaction( input.roundIds.map((roundId, index) => ctx.prisma.round.update({ where: { id: roundId }, data: { sortOrder: index }, }) ) ) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'REORDER_ROUNDS', entityType: 'Program', entityId: input.programId, detailsJson: { roundIds: input.roundIds }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true } }), })