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

@@ -177,8 +177,9 @@ export async function cancelIntent(
export async function expireIntentsForRound(
roundId: string,
actorId?: string,
txClient?: Prisma.TransactionClient,
): Promise<{ expired: number }> {
return prisma.$transaction(async (tx) => {
const run = async (tx: Prisma.TransactionClient) => {
const pending = await tx.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
})
@@ -208,7 +209,13 @@ export async function expireIntentsForRound(
})
return { expired: pending.length }
})
}
// If a transaction client was provided, use it directly; otherwise open a new one
if (txClient) {
return run(txClient)
}
return prisma.$transaction(run)
}
// ============================================================================

View File

@@ -235,7 +235,7 @@ async function sendRemindersForRound(
}
// Select email template type based on reminder type
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : type === '3_DAYS' ? 'REMINDER_3_DAYS' : 'REMINDER_24H'
for (const user of users) {
const pendingCount = pendingCounts.get(user.id) || 0

View File

@@ -268,9 +268,15 @@ export async function createBulkNotifications(params: {
})),
})
// Check email settings and send emails
for (const userId of userIds) {
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
// Check email settings once, then send emails only if enabled
const emailSetting = await prisma.notificationEmailSetting.findUnique({
where: { notificationType: type },
})
if (emailSetting?.sendEmail) {
for (const userId of userIds) {
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
}
}
}
@@ -390,19 +396,36 @@ async function maybeSendEmail(
return
}
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
} catch (error) {
// Log but don't fail the notification creation
console.error('[Notification] Failed to send email:', error)
}
}
/**
* Send email to a user using a pre-fetched email setting (skips the setting lookup)
*/
async function maybeSendEmailWithSetting(
userId: string,
type: string,
title: string,
message: string,
emailSetting: { sendEmail: boolean; emailSubject: string | null },
linkUrl?: string,
metadata?: Record<string, unknown>
): Promise<void> {
try {
// Check user's notification preference
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, name: true, notificationPreference: true },
})
if (!user || user.notificationPreference === 'NONE') {
if (!user || (user.notificationPreference !== 'EMAIL' && user.notificationPreference !== 'BOTH')) {
return
}
// Send styled email with full context
// The styled template will use metadata for rich content
// Subject can be overridden by admin settings
await sendStyledNotificationEmail(
user.email,
user.name || 'User',
@@ -416,7 +439,6 @@ async function maybeSendEmail(
emailSetting.emailSubject || undefined
)
} catch (error) {
// Log but don't fail the notification creation
console.error('[Notification] Failed to send email:', error)
}
}

View File

@@ -54,6 +54,15 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
ROUND_ARCHIVED: [],
}
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
PASSED: ['COMPLETED', 'WITHDRAWN'],
REJECTED: ['PENDING'], // re-include
COMPLETED: [], // terminal
WITHDRAWN: ['PENDING'], // re-include
}
// ─── Round-Level Transitions ────────────────────────────────────────────────
/**
@@ -232,8 +241,8 @@ export async function closeRound(
data: { status: 'ROUND_CLOSED' },
})
// Expire pending intents
await expireIntentsForRound(roundId, actorId)
// Expire pending intents (using the transaction client)
await expireIntentsForRound(roundId, actorId, tx)
// Auto-close any preceding active rounds (lower sortOrder, same competition)
const precedingActiveRounds = await tx.round.findMany({
@@ -540,6 +549,7 @@ export async function transitionProject(
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
options?: { adminOverride?: boolean },
): Promise<ProjectRoundTransitionResult> {
try {
const round = await prisma.round.findUnique({ where: { id: roundId } })
@@ -569,6 +579,17 @@ export async function transitionProject(
where: { projectId_roundId: { projectId, roundId } },
})
// Enforce project state transition whitelist (unless admin override)
if (existing && !options?.adminOverride) {
const currentState = existing.state as string
const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? []
if (!allowed.includes(newState)) {
throw new Error(
`Invalid project transition: ${currentState}${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`,
)
}
}
let prs
if (existing) {
prs = await tx.projectRoundState.update({
@@ -649,6 +670,7 @@ export async function batchTransitionProjects(
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
options?: { adminOverride?: boolean },
): Promise<BatchProjectTransitionResult> {
const succeeded: string[] = []
const failed: Array<{ projectId: string; errors: string[] }> = []
@@ -657,7 +679,7 @@ export async function batchTransitionProjects(
const batch = projectIds.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (projectId) => {
const result = await transitionProject(projectId, roundId, newState, actorId, prisma)
const result = await transitionProject(projectId, roundId, newState, actorId, prisma, options)
if (result.success) {
succeeded.push(projectId)
@@ -725,35 +747,74 @@ export async function checkRequirementsAndTransition(
prisma: PrismaClient | any,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
// Get all required FileRequirements for this round
// Get all required FileRequirements for this round (legacy model)
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
// If the round has no file requirements, nothing to check
if (requirements.length === 0) {
// Also check SubmissionFileRequirement via the round's submissionWindow
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If the round has no file requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
return { transitioned: false }
}
// Check which requirements this project has satisfied (has a file uploaded)
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
// Check which legacy requirements this project has satisfied
let legacyAllMet = true
if (requirements.length > 0) {
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
// Check if all required requirements are met
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
}
// Check which SubmissionFileRequirements this project has satisfied
let submissionAllMet = true
if (submissionRequirements.length > 0) {
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
where: {
projectId,
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
},
select: { submissionFileRequirementId: true },
})
const fulfilledSubIds = new Set(
fulfilledSubmissionFiles
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
.filter(Boolean)
)
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
}
// All requirements from both models must be met
const allMet = legacyAllMet && submissionAllMet
if (!allMet) {
return { transitioned: false }