Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,9 +132,9 @@ export const evaluationRouter = router({
|
||||
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),
|
||||
globalScore: z.number().int().min(1).max(10).optional(),
|
||||
binaryDecision: z.boolean().optional(),
|
||||
feedbackText: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -152,6 +152,17 @@ export const evaluationRouter = router({
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
// Server-side COI check
|
||||
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
||||
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
||||
})
|
||||
if (coi) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Cannot submit evaluation — conflict of interest declared',
|
||||
})
|
||||
}
|
||||
|
||||
// Check voting window via round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: evaluation.assignment.roundId },
|
||||
@@ -194,12 +205,66 @@ export const evaluationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Load round config for validation
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
const scoringMode = (config.scoringMode as string) || 'criteria'
|
||||
|
||||
// Fix 3: Dynamic feedback validation based on config
|
||||
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`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 4: Normalize binaryDecision and globalScore based on scoringMode
|
||||
if (scoringMode !== 'binary') {
|
||||
data.binaryDecision = undefined
|
||||
}
|
||||
if (scoringMode === 'binary') {
|
||||
data.globalScore = undefined
|
||||
}
|
||||
|
||||
// Fix 5: requireAllCriteriaScored validation
|
||||
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: round.id, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
if (evalForm?.criteriaJson) {
|
||||
const criteria = evalForm.criteriaJson as Array<{ id: 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) => !scores || typeof scores[c.id] !== 'number'
|
||||
)
|
||||
if (missingCriteria.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.id).join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit evaluation and mark assignment as completed atomically
|
||||
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: {
|
||||
...data,
|
||||
...saveData,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
},
|
||||
@@ -784,7 +849,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
if (!settings.peer_review_enabled) {
|
||||
if (!settings.peerReviewEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Peer review is not enabled for this stage',
|
||||
@@ -843,7 +908,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
// Anonymize individual scores based on round settings
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const individualScores = evaluations.map((e) => {
|
||||
let jurorLabel: string
|
||||
@@ -926,7 +991,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
||||
let authorLabel: string
|
||||
@@ -978,7 +1043,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const maxLength = (settings.max_comment_length as number) || 2000
|
||||
const maxLength = (settings.maxCommentLength as number) || 2000
|
||||
if (input.content.length > maxLength) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
|
||||
Reference in New Issue
Block a user