feat: admin evaluation editing, ranking improvements, status transition fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m26s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m26s
- Add adminEditEvaluation mutation and getJurorEvaluations query - Create shared EvaluationEditSheet component with inline feedback editing - Add Evaluations tab to member detail page (grouped by round) - Make jury group member names clickable (link to member detail) - Replace inline EvaluationDetailSheet on project page with shared component - Fix project status transition validation (skip when status unchanged) - Fix frontend to not send status when unchanged on project edit - Ranking dashboard improvements and boolean decision converter fixes - Backfill script updates for binary decisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1780,4 +1780,87 @@ export const evaluationRouter = router({
|
||||
submissions,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: edit the feedbackText on a submitted evaluation.
|
||||
*/
|
||||
adminEditEvaluation: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
evaluationId: z.string(),
|
||||
feedbackText: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const evaluation = await ctx.prisma.evaluation.findUnique({
|
||||
where: { id: input.evaluationId },
|
||||
select: { id: true, feedbackText: true },
|
||||
})
|
||||
|
||||
if (!evaluation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Evaluation not found' })
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.evaluation.update({
|
||||
where: { id: input.evaluationId },
|
||||
data: { feedbackText: input.feedbackText },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ADMIN_EDIT_EVALUATION_FEEDBACK',
|
||||
entityType: 'Evaluation',
|
||||
entityId: input.evaluationId,
|
||||
detailsJson: {
|
||||
adminUserId: ctx.user.id,
|
||||
before: (evaluation.feedbackText ?? '').slice(0, 200),
|
||||
after: input.feedbackText.slice(0, 200),
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: get all evaluations submitted by a specific juror.
|
||||
*/
|
||||
getJurorEvaluations: adminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
||||
evaluation: {
|
||||
select: {
|
||||
id: true,
|
||||
globalScore: true,
|
||||
binaryDecision: true,
|
||||
feedbackText: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
criterionScoresJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ round: { sortOrder: 'asc' } },
|
||||
{ project: { title: 'asc' } },
|
||||
],
|
||||
})
|
||||
|
||||
return assignments
|
||||
.filter((a) => a.evaluation !== null)
|
||||
.map((a) => ({
|
||||
assignmentId: a.id,
|
||||
roundId: a.roundId,
|
||||
roundName: a.round.name,
|
||||
roundType: a.round.roundType,
|
||||
projectId: a.project.id,
|
||||
projectTitle: a.project.title,
|
||||
evaluation: a.evaluation!,
|
||||
}))
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -721,18 +721,20 @@ export const projectRouter = router({
|
||||
? (country === null ? null : normalizeCountryToCode(country))
|
||||
: undefined
|
||||
|
||||
// Validate status transition if status is being changed
|
||||
// Validate status transition if status is actually changing
|
||||
if (status) {
|
||||
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
})
|
||||
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
|
||||
if (!allowedTransitions.includes(status)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
|
||||
})
|
||||
if (status !== currentProject.status) {
|
||||
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
|
||||
if (!allowedTransitions.includes(status)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -387,4 +387,79 @@ export const rankingRouter = router({
|
||||
triggered: results.filter((r) => r.triggered).length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get per-project evaluation scores for a round.
|
||||
* Returns a map of projectId → array of { jurorName, globalScore, binaryDecision }.
|
||||
* Used by the ranking dashboard to show individual juror scores inline.
|
||||
*/
|
||||
roundEvaluationScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Fetch the round config to find the boolean criterion ID (legacy fallback)
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
||||
const criteria = (roundConfig?.criteria ?? roundConfig?.evaluationCriteria ?? []) as Array<{
|
||||
id: string
|
||||
label: string
|
||||
type?: string
|
||||
}>
|
||||
const boolCriterionId = criteria.find(
|
||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||
)?.id ?? null
|
||||
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
isRequired: true,
|
||||
evaluation: { status: 'SUBMITTED' },
|
||||
},
|
||||
select: {
|
||||
projectId: true,
|
||||
user: { select: { name: true, email: true } },
|
||||
evaluation: {
|
||||
select: {
|
||||
globalScore: true,
|
||||
binaryDecision: true,
|
||||
criterionScoresJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const byProject: Record<string, Array<{
|
||||
jurorName: string
|
||||
globalScore: number | null
|
||||
decision: boolean | null
|
||||
}>> = {}
|
||||
|
||||
for (const a of assignments) {
|
||||
if (!a.evaluation) continue
|
||||
const list = byProject[a.projectId] ?? []
|
||||
|
||||
// Resolve binary decision: column first, then criterion fallback
|
||||
let decision = a.evaluation.binaryDecision
|
||||
if (decision == null && boolCriterionId) {
|
||||
const scores = a.evaluation.criterionScoresJson as Record<string, unknown> | null
|
||||
if (scores) {
|
||||
const val = scores[boolCriterionId]
|
||||
if (typeof val === 'boolean') decision = val
|
||||
else if (val === 'true') decision = true
|
||||
else if (val === 'false') decision = false
|
||||
}
|
||||
}
|
||||
|
||||
list.push({
|
||||
jurorName: a.user.name ?? a.user.email ?? 'Unknown',
|
||||
globalScore: a.evaluation.globalScore,
|
||||
decision,
|
||||
})
|
||||
byProject[a.projectId] = list
|
||||
}
|
||||
|
||||
return byProject
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -160,13 +160,49 @@ function anonymizeProjectsForRanking(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pass rate from Evaluation records.
|
||||
* Handles both legacy binaryDecision boolean and future dedicated field.
|
||||
* Falls back to binaryDecision if no future field exists.
|
||||
* Find the boolean criterion ID for "Move to the Next Stage?" from round config.
|
||||
* Returns null if no such criterion exists.
|
||||
*/
|
||||
function computePassRate(evaluations: Array<{ binaryDecision: boolean | null }>): number {
|
||||
function findBooleanCriterionId(roundConfig: Record<string, unknown> | null): string | null {
|
||||
if (!roundConfig) return null
|
||||
const criteria = (roundConfig.criteria ?? roundConfig.evaluationCriteria ?? []) as Array<{
|
||||
id: string
|
||||
label: string
|
||||
type?: string
|
||||
}>
|
||||
const boolCriterion = criteria.find(
|
||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||
)
|
||||
return boolCriterion?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the binary advance decision for an evaluation.
|
||||
* 1. Use binaryDecision column if set
|
||||
* 2. Fall back to the boolean criterion in criterionScoresJson
|
||||
*/
|
||||
function resolveBinaryDecision(
|
||||
binaryDecision: boolean | null,
|
||||
criterionScoresJson: Record<string, unknown> | null,
|
||||
boolCriterionId: string | null,
|
||||
): boolean | null {
|
||||
if (binaryDecision != null) return binaryDecision
|
||||
if (!boolCriterionId || !criterionScoresJson) return null
|
||||
const value = criterionScoresJson[boolCriterionId]
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pass rate from Evaluation records.
|
||||
* Counts evaluations where the advance decision resolved to true.
|
||||
* Evaluations with null decision are treated as "no" (not counted as pass).
|
||||
*/
|
||||
function computePassRate(evaluations: Array<{ resolvedDecision: boolean | null }>): number {
|
||||
if (evaluations.length === 0) return 0
|
||||
const passCount = evaluations.filter((e) => e.binaryDecision === true).length
|
||||
const passCount = evaluations.filter((e) => e.resolvedDecision === true).length
|
||||
return passCount / evaluations.length
|
||||
}
|
||||
|
||||
@@ -377,6 +413,13 @@ export async function fetchAndRankCategory(
|
||||
prisma: PrismaClient,
|
||||
userId?: string,
|
||||
): Promise<RankingResult> {
|
||||
// Fetch the round config to find the boolean criterion ID (legacy fallback)
|
||||
const round = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const boolCriterionId = findBooleanCriterionId(round.configJson as Record<string, unknown> | null)
|
||||
|
||||
// Query submitted evaluations grouped by projectId for this category
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
@@ -395,7 +438,7 @@ export async function fetchAndRankCategory(
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
select: { globalScore: true, binaryDecision: true },
|
||||
select: { globalScore: true, binaryDecision: true, criterionScoresJson: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, competitionCategory: true },
|
||||
@@ -403,12 +446,17 @@ export async function fetchAndRankCategory(
|
||||
},
|
||||
})
|
||||
|
||||
// Group by projectId
|
||||
const byProject = new Map<string, Array<{ globalScore: number | null; binaryDecision: boolean | null }>>()
|
||||
// Group by projectId, resolving binaryDecision from column or criterionScoresJson fallback
|
||||
const byProject = new Map<string, Array<{ globalScore: number | null; resolvedDecision: boolean | null }>>()
|
||||
for (const a of assignments) {
|
||||
if (!a.evaluation) continue
|
||||
const resolved = resolveBinaryDecision(
|
||||
a.evaluation.binaryDecision,
|
||||
a.evaluation.criterionScoresJson as Record<string, unknown> | null,
|
||||
boolCriterionId,
|
||||
)
|
||||
const list = byProject.get(a.project.id) ?? []
|
||||
list.push({ globalScore: a.evaluation.globalScore, binaryDecision: a.evaluation.binaryDecision })
|
||||
list.push({ globalScore: a.evaluation.globalScore, resolvedDecision: resolved })
|
||||
byProject.set(a.project.id, list)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user