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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user