import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' 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: { createdAt: '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), requiredReviews: z.number().int().min(1).max(10).default(3), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { // 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', }) } } const round = await ctx.prisma.round.create({ data: input, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'Round', entityId: round.id, detailsJson: input, 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(), submissionDeadline: z.date().optional().nullable(), votingStartAt: z.date().optional().nullable(), votingEndAt: z.date().optional().nullable(), settingsJson: z.record(z.unknown()).optional(), }) ) .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', }) } } const round = await ctx.prisma.round.update({ where: { id }, data: { ...data, 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 }) => { const round = await ctx.prisma.round.update({ where: { id: input.id }, data: { status: input.status }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_STATUS', entityType: 'Round', entityId: input.id, detailsJson: { status: input.status }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) 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 }), })