feat: admin can fill in evaluations on behalf of jurors
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m54s
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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user