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

@@ -16,7 +16,6 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail } from '@/lib/email'
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
try {
@@ -31,11 +30,12 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
name: true,
configJson: true,
competitionId: true,
juryGroupId: true,
},
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
@@ -45,8 +45,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
(config.maxAssignmentsPerJuror as number) ??
20
// Scope jurors to jury group if the round has one assigned
let scopedJurorIds: string[] | undefined
if (round.juryGroupId) {
const groupMembers = await prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
select: { userId: true },
})
scopedJurorIds = groupMembers.map((m) => m.userId)
}
const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
id: true,
name: true,
@@ -96,6 +110,18 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
select: { userId: true, projectId: true },
})
// Query COI records for this round to exclude conflicted juror-project pairs
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
roundId,
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiExclusions = new Set(
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
)
// Calculate batch info
const BATCH_SIZE = 15
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
@@ -144,8 +170,13 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
onProgress
)
// Filter out suggestions that conflict with COI declarations
const filteredSuggestions = coiExclusions.size > 0
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
: result.suggestions
// Enrich suggestions with names for storage
const enrichedSuggestions = result.suggestions.map((s) => {
const enrichedSuggestions = filteredSuggestions.map((s) => {
const juror = jurors.find((j) => j.id === s.jurorId)
const project = projects.find((p) => p.id === s.projectId)
return {
@@ -162,7 +193,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
status: 'COMPLETED',
completedAt: new Date(),
processedCount: projects.length,
suggestionsCount: result.suggestions.length,
suggestionsCount: filteredSuggestions.length,
suggestionsJson: enrichedSuggestions,
fallbackUsed: result.fallbackUsed ?? false,
},
@@ -171,7 +202,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
await notifyAdmins({
type: NotificationTypes.AI_SUGGESTIONS_READY,
title: 'AI Assignment Suggestions Ready',
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Suggestions',
priority: 'high',
@@ -179,7 +210,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
roundId,
jobId,
projectCount: projects.length,
suggestionsCount: result.suggestions.length,
suggestionsCount: filteredSuggestions.length,
fallbackUsed: result.fallbackUsed,
},
})
@@ -425,7 +456,7 @@ export const assignmentRouter = router({
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
stageName: stageInfo.name,
roundName: stageInfo.name,
deadline,
assignmentId: assignment.id,
},
@@ -567,7 +598,7 @@ export const assignmentRouter = router({
linkLabel: 'View Assignments',
metadata: {
projectCount,
stageName: stage?.name,
roundName: stage?.name,
deadline,
},
})
@@ -621,7 +652,7 @@ export const assignmentRouter = router({
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
@@ -692,7 +723,7 @@ export const assignmentRouter = router({
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
@@ -1100,7 +1131,7 @@ export const assignmentRouter = router({
linkLabel: 'View Assignments',
metadata: {
projectCount,
stageName: stage?.name,
roundName: stage?.name,
deadline,
},
})
@@ -1252,7 +1283,7 @@ export const assignmentRouter = router({
linkLabel: 'View Assignments',
metadata: {
projectCount,
stageName: stage?.name,
roundName: stage?.name,
deadline,
},
})
@@ -1361,7 +1392,7 @@ export const assignmentRouter = router({
/**
* Notify all jurors of their current assignments for a round (admin only).
* Sends both in-app notifications AND direct emails to each juror.
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
*/
notifyJurorsOfAssignments: adminProcedure
.input(z.object({ roundId: z.string() }))
@@ -1378,7 +1409,7 @@ export const assignmentRouter = router({
})
if (assignments.length === 0) {
return { sent: 0, jurorCount: 0, emailsSent: 0 }
return { sent: 0, jurorCount: 0 }
}
// Count assignments per user
@@ -1414,44 +1445,11 @@ export const assignmentRouter = router({
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { projectCount, stageName: round.name, deadline },
metadata: { projectCount, roundName: round.name, deadline },
})
totalSent += userIds.length
}
// Send direct emails to every juror (regardless of notification email settings)
const allUserIds = Object.keys(userCounts)
const users = await ctx.prisma.user.findMany({
where: { id: { in: allUserIds } },
select: { id: true, name: true, email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
let emailsSent = 0
for (const user of users) {
const projectCount = userCounts[user.id] || 0
if (projectCount === 0) continue
try {
await sendStyledNotificationEmail(
user.email,
user.name || '',
'BATCH_ASSIGNED',
{
name: user.name || undefined,
title: `Projects Assigned - ${round.name}`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name}.`,
linkUrl: `${baseUrl}/jury/competitions`,
metadata: { projectCount, roundName: round.name, deadline },
}
)
emailsSent++
} catch (error) {
console.error(`Failed to send assignment email to ${user.email}:`, error)
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
@@ -1461,12 +1459,11 @@ export const assignmentRouter = router({
detailsJson: {
jurorCount: Object.keys(userCounts).length,
totalAssignments: assignments.length,
emailsSent,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: totalSent, jurorCount: Object.keys(userCounts).length, emailsSent }
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
}),
})