import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' export const evaluationRouter = router({ /** * Get evaluation for an assignment */ get: protectedProcedure .input(z.object({ assignmentId: z.string() })) .query(async ({ ctx, input }) => { // Verify ownership or admin const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.assignmentId }, include: { round: true }, }) if ( ctx.user.role === 'JURY_MEMBER' && assignment.userId !== ctx.user.id ) { throw new TRPCError({ code: 'FORBIDDEN' }) } return ctx.prisma.evaluation.findUnique({ where: { assignmentId: input.assignmentId }, include: { form: true, }, }) }), /** * Start an evaluation (creates draft) */ start: protectedProcedure .input( z.object({ assignmentId: z.string(), }) ) .mutation(async ({ ctx, input }) => { // Verify assignment ownership const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.assignmentId }, include: { round: { include: { evaluationForms: { where: { isActive: true }, take: 1 }, }, }, }, }) if (assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } // Get active form const form = assignment.round.evaluationForms[0] if (!form) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active evaluation form for this round', }) } // Check if evaluation exists const existing = await ctx.prisma.evaluation.findUnique({ where: { assignmentId: input.assignmentId }, }) if (existing) return existing return ctx.prisma.evaluation.create({ data: { assignmentId: input.assignmentId, formId: form.id, status: 'DRAFT', }, }) }), /** * Autosave evaluation (debounced on client) */ autosave: protectedProcedure .input( z.object({ id: z.string(), criterionScoresJson: z.record(z.number()).optional(), globalScore: z.number().int().min(1).max(10).optional().nullable(), binaryDecision: z.boolean().optional().nullable(), feedbackText: z.string().optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input // Verify ownership and status const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ where: { id }, include: { assignment: true }, }) if (evaluation.assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } if ( evaluation.status === 'SUBMITTED' || evaluation.status === 'LOCKED' ) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot edit submitted evaluation', }) } return ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'DRAFT', }, }) }), /** * Submit evaluation (final) */ submit: protectedProcedure .input( z.object({ id: z.string(), criterionScoresJson: z.record(z.number()), globalScore: z.number().int().min(1).max(10), binaryDecision: z.boolean(), feedbackText: z.string().min(10), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input // Verify ownership const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ where: { id }, include: { assignment: { include: { round: true }, }, }, }) if (evaluation.assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } // Check voting window const round = evaluation.assignment.round const now = new Date() if (round.status !== 'ACTIVE') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round is not active', }) } // Check for grace period const gracePeriod = await ctx.prisma.gracePeriod.findFirst({ where: { roundId: round.id, userId: ctx.user.id, OR: [ { projectId: null }, { projectId: evaluation.assignment.projectId }, ], extendedUntil: { gte: now }, }, }) const effectiveEndDate = gracePeriod?.extendedUntil ?? round.votingEndAt if (round.votingStartAt && now < round.votingStartAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting has not started yet', }) } if (effectiveEndDate && now > effectiveEndDate) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting window has closed', }) } // Submit const updated = await ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'SUBMITTED', submittedAt: now, }, }) // Mark assignment as completed await ctx.prisma.assignment.update({ where: { id: evaluation.assignmentId }, data: { isCompleted: true }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'SUBMIT_EVALUATION', entityType: 'Evaluation', entityId: id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return updated }), /** * Get aggregated stats for a project (admin only) */ getProjectStats: adminProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { projectId: input.projectId }, }, }) if (evaluations.length === 0) { return null } const globalScores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const yesVotes = evaluations.filter( (e) => e.binaryDecision === true ).length return { totalEvaluations: evaluations.length, averageGlobalScore: globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : null, minScore: globalScores.length > 0 ? Math.min(...globalScores) : null, maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null, yesVotes, noVotes: evaluations.length - yesVotes, yesPercentage: (yesVotes / evaluations.length) * 100, } }), /** * Get all evaluations for a round (admin only) */ listByRound: adminProcedure .input( z.object({ roundId: z.string(), status: z.enum(['NOT_STARTED', 'DRAFT', 'SUBMITTED', 'LOCKED']).optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: input.roundId }, ...(input.status && { status: input.status }), }, include: { assignment: { include: { user: { select: { id: true, name: true, email: true } }, project: { select: { id: true, title: true } }, }, }, }, orderBy: { updatedAt: 'desc' }, }) }), /** * Get my past evaluations (read-only for jury) */ myPastEvaluations: protectedProcedure .input(z.object({ roundId: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.prisma.evaluation.findMany({ where: { assignment: { userId: ctx.user.id, ...(input.roundId && { roundId: input.roundId }), }, status: 'SUBMITTED', }, include: { assignment: { include: { project: { select: { id: true, title: true } }, round: { select: { id: true, name: true } }, }, }, }, orderBy: { submittedAt: 'desc' }, }) }), })