feat: admin can fill in evaluations on behalf of jurors
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s

When a juror cannot connect during an evaluation round, an admin
can now submit evaluations for them.

Router — new admin procedures:
- adminStart / adminAutosave: create and save drafts for any juror.
- adminSubmitOnBehalf: submit bypassing ROUND_ACTIVE and voting-window
  checks. COI block and feedback/criterion validation still enforced.
  Audit log records both admin and juror IDs plus bypassedWindow flag.
- getJurorAssignmentsForRound: list a juror's assignments + eval state.

UI — two new admin pages under /admin/rounds/[roundId]/jurors/[userId]/:
- evaluate: list of pending + completed assignments, COI flagged.
- evaluate/[projectId]: evaluation form reusing the juror's scoring UI,
  with an "acting on behalf" banner and confirmation dialog before
  submit. Back button returns to the assignments list.

Entry point: FilePen icon on each juror row in JuryProgressTable.

Refactor: extracted the scoring form JSX into shared
EvaluationFormFields component so the juror page and the admin proxy
page render identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-21 16:41:14 +02:00
parent fd4f6dde16
commit 9cb3b9de13
6 changed files with 1547 additions and 358 deletions

View File

@@ -1881,4 +1881,288 @@ export const evaluationRouter = router({
evaluation: a.evaluation!,
}))
}),
// =========================================================================
// Admin Proxy Evaluation — fill in evaluations on behalf of a juror who
// could not submit themselves (e.g. access issues). Bypasses voting window
// and ownership checks. Every proxy submission is audit-logged with both
// the admin userId and the juror userId whose assignment was completed.
// =========================================================================
/**
* Admin: start (or reuse) a draft evaluation for any juror's assignment.
*/
adminStart: adminProcedure
.input(z.object({ assignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: { project: { select: { competitionCategory: true } } },
})
const form = await findActiveForm(
ctx.prisma,
assignment.roundId,
assignment.project.competitionCategory,
)
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No active evaluation form for this stage',
})
}
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',
},
})
}),
/**
* Admin: autosave a draft evaluation. No ownership check; refuses if the
* evaluation has already been SUBMITTED or LOCKED.
*/
adminAutosave: adminProcedure
.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
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
where: { id },
})
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' },
})
}),
/**
* Admin: submit an evaluation on behalf of a juror. Bypasses round-active
* and voting-window checks. Still enforces COI (juror-declared conflicts
* must be resolved via reassignment) and feedback/criterion validation.
*/
adminSubmitOnBehalf: adminProcedure
.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).optional(),
binaryDecision: z.boolean().optional(),
feedbackText: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
where: { id },
include: {
assignment: true,
form: { select: { criteriaJson: true } },
},
})
// COI still blocks: if the juror declared a conflict, the correct path
// is to reassign, not to proxy-submit.
const coi = await ctx.prisma.conflictOfInterest.findFirst({
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
})
if (coi) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot submit — juror declared a conflict of interest. Reassign this project first.',
})
}
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: evaluation.assignment.roundId },
})
const config = (round.configJson as Record<string, unknown>) || {}
const scoringMode = (config.scoringMode as string) || 'criteria'
const requireFeedback = config.requireFeedback !== false
if (requireFeedback) {
const feedbackMinLength = (config.feedbackMinLength as number) || 10
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Feedback must be at least ${feedbackMinLength} characters`,
})
}
}
if (scoringMode !== 'binary') {
data.binaryDecision = undefined
}
if (scoringMode === 'binary') {
data.globalScore = undefined
}
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
const evalForm = evaluation.form
if (evalForm?.criteriaJson) {
const criteria = evalForm.criteriaJson as Array<{ id: string; label?: string; type?: string; required?: boolean }>
const scorableCriteria = criteria.filter(
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
)
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
const missingCriteria = scorableCriteria.filter((c) => {
if (!scores) return true
const val = scores[c.id]
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
return typeof val !== 'number'
})
if (missingCriteria.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.label || c.id).join(', ')}`,
})
}
}
}
const now = new Date()
const saveData = {
criterionScoresJson: data.criterionScoresJson,
globalScore: data.globalScore ?? null,
binaryDecision: data.binaryDecision ?? null,
feedbackText: data.feedbackText ?? null,
}
const [updated] = await ctx.prisma.$transaction([
ctx.prisma.evaluation.update({
where: { id },
data: {
...saveData,
status: 'SUBMITTED',
submittedAt: now,
},
}),
ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId },
data: { isCompleted: true },
}),
])
triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id).catch((err) => {
console.error('[Evaluation] triggerAutoRankIfComplete failed (admin proxy):', err)
})
await triggerInProgressOnActivity(
evaluation.assignment.projectId,
evaluation.assignment.roundId,
ctx.user.id,
ctx.prisma,
)
await checkEvaluationCompletionAndTransition(
evaluation.assignment.projectId,
evaluation.assignment.roundId,
ctx.user.id,
ctx.prisma,
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADMIN_PROXY_EVAL_SUBMITTED',
entityType: 'Evaluation',
entityId: id,
detailsJson: {
adminUserId: ctx.user.id,
onBehalfOfUserId: evaluation.assignment.userId,
assignmentId: evaluation.assignmentId,
projectId: evaluation.assignment.projectId,
roundId: evaluation.assignment.roundId,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
windowCloseAt: round.windowCloseAt,
roundStatus: round.status,
bypassedWindow: round.status !== 'ROUND_ACTIVE' || (round.windowCloseAt ? now > round.windowCloseAt : false),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Admin: list all assignments for a specific juror in a round, including
* project details and current evaluation status. Drives the admin proxy-
* evaluation flow (list → pick project → fill in).
*/
getJurorAssignmentsForRound: adminProcedure
.input(z.object({ roundId: z.string(), userId: z.string() }))
.query(async ({ ctx, input }) => {
const [juror, round, assignments] = await Promise.all([
ctx.prisma.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { id: true, name: true, roundType: true, status: true, windowCloseAt: true, competitionId: true },
}),
ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
userId: input.userId,
},
include: {
project: {
select: {
id: true,
title: true,
competitionCategory: true,
teamName: true,
},
},
evaluation: {
select: {
id: true,
status: true,
globalScore: true,
binaryDecision: true,
submittedAt: true,
},
},
conflictOfInterest: {
select: {
id: true,
hasConflict: true,
},
},
},
orderBy: [{ isCompleted: 'asc' }, { project: { title: 'asc' } }],
}),
])
if (!juror || !round) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror or round not found' })
}
return { juror, round, assignments }
}),
})