import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' import { processEvaluationReminders } from '../services/evaluation-reminders' import { generateSummary } from '@/server/services/ai-evaluation-summary' 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.union([z.number(), z.string(), z.boolean()])).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.union([z.number(), z.string(), z.boolean()])), 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 evaluation and mark assignment as completed atomically const [updated] = await ctx.prisma.$transaction([ ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'SUBMITTED', submittedAt: now, }, }), ctx.prisma.assignment.update({ where: { id: evaluation.assignmentId }, data: { isCompleted: true }, }), ]) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'EVALUATION_SUBMITTED', entityType: 'Evaluation', entityId: id, detailsJson: { projectId: evaluation.assignment.projectId, roundId: evaluation.assignment.roundId, globalScore: data.globalScore, binaryDecision: data.binaryDecision, }, 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' }, }) }), // ========================================================================= // Conflict of Interest (COI) Endpoints // ========================================================================= /** * Declare a conflict of interest for an assignment */ declareCOI: protectedProcedure .input( z.object({ assignmentId: z.string(), hasConflict: z.boolean(), conflictType: z.string().optional(), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Look up the assignment to get projectId, roundId, userId const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.assignmentId }, include: { project: { select: { title: true } }, round: { select: { name: true } }, }, }) // Verify ownership if (assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } // Upsert COI record const coi = await ctx.prisma.conflictOfInterest.upsert({ where: { assignmentId: input.assignmentId }, create: { assignmentId: input.assignmentId, userId: ctx.user.id, projectId: assignment.projectId, roundId: assignment.roundId, hasConflict: input.hasConflict, conflictType: input.hasConflict ? input.conflictType : null, description: input.hasConflict ? input.description : null, }, update: { hasConflict: input.hasConflict, conflictType: input.hasConflict ? input.conflictType : null, description: input.hasConflict ? input.description : null, declaredAt: new Date(), }, }) // Notify admins if conflict declared if (input.hasConflict) { await notifyAdmins({ type: NotificationTypes.JURY_INACTIVE, title: 'Conflict of Interest Declared', message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`, linkUrl: `/admin/rounds/${assignment.roundId}/coi`, linkLabel: 'Review COI', priority: 'high', metadata: { assignmentId: input.assignmentId, userId: ctx.user.id, projectId: assignment.projectId, roundId: assignment.roundId, conflictType: input.conflictType, }, }) } // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'COI_DECLARED', entityType: 'ConflictOfInterest', entityId: coi.id, detailsJson: { assignmentId: input.assignmentId, projectId: assignment.projectId, roundId: assignment.roundId, hasConflict: input.hasConflict, conflictType: input.conflictType, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return coi }), /** * Get COI status for an assignment */ getCOIStatus: protectedProcedure .input(z.object({ assignmentId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.conflictOfInterest.findUnique({ where: { assignmentId: input.assignmentId }, }) }), /** * List COI declarations for a round (admin only) */ listCOIByRound: adminProcedure .input( z.object({ roundId: z.string(), hasConflictOnly: z.boolean().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.conflictOfInterest.findMany({ where: { roundId: input.roundId, ...(input.hasConflictOnly && { hasConflict: true }), }, include: { user: { select: { id: true, name: true, email: true } }, assignment: { include: { project: { select: { id: true, title: true } }, }, }, reviewedBy: { select: { id: true, name: true, email: true } }, }, orderBy: { declaredAt: 'desc' }, }) }), /** * Review a COI declaration (admin only) */ reviewCOI: adminProcedure .input( z.object({ id: z.string(), reviewAction: z.enum(['cleared', 'reassigned', 'noted']), }) ) .mutation(async ({ ctx, input }) => { const coi = await ctx.prisma.conflictOfInterest.update({ where: { id: input.id }, data: { reviewedById: ctx.user.id, reviewedAt: new Date(), reviewAction: input.reviewAction, }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'COI_REVIEWED', entityType: 'ConflictOfInterest', entityId: input.id, detailsJson: { reviewAction: input.reviewAction, assignmentId: coi.assignmentId, userId: coi.userId, projectId: coi.projectId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return coi }), // ========================================================================= // Reminder Triggers // ========================================================================= /** * Manually trigger reminder check for a specific round (admin only) */ triggerReminders: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await processEvaluationReminders(input.roundId) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'REMINDERS_TRIGGERED', entityType: 'Round', entityId: input.roundId, detailsJson: { sent: result.sent, errors: result.errors, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }), // ========================================================================= // AI Evaluation Summary Endpoints // ========================================================================= /** * Generate an AI-powered evaluation summary for a project (admin only) */ generateSummary: adminProcedure .input( z.object({ projectId: z.string(), roundId: z.string(), }) ) .mutation(async ({ ctx, input }) => { return generateSummary({ projectId: input.projectId, roundId: input.roundId, userId: ctx.user.id, prisma: ctx.prisma, }) }), /** * Get an existing evaluation summary for a project (admin only) */ getSummary: adminProcedure .input( z.object({ projectId: z.string(), roundId: z.string(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.evaluationSummary.findUnique({ where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId, }, }, }) }), /** * Generate summaries for all projects in a round with submitted evaluations (admin only) */ generateBulkSummaries: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { // Find all projects in the round with at least 1 submitted evaluation const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId, assignments: { some: { evaluation: { status: 'SUBMITTED', }, }, }, }, select: { id: true }, }) let generated = 0 const errors: Array<{ projectId: string; error: string }> = [] // Generate summaries sequentially to avoid rate limits for (const project of projects) { try { await generateSummary({ projectId: project.id, roundId: input.roundId, userId: ctx.user.id, prisma: ctx.prisma, }) generated++ } catch (error) { errors.push({ projectId: project.id, error: error instanceof Error ? error.message : 'Unknown error', }) } } return { total: projects.length, generated, errors, } }), })