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

## 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:
Matt
2026-02-19 12:59:35 +01:00
parent ee8b12e59c
commit baca483fcb
12 changed files with 1814 additions and 609 deletions

View File

@@ -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',