MOPC-App/src/server/routers/evaluation.ts

625 lines
17 KiB
TypeScript
Raw Normal View History

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',
})
}
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
// 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,
}
}),
})