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

@@ -1,10 +1,12 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { Prisma, type PrismaClient } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
import { generateShortlist } from '../services/ai-shortlist'
import { createBulkNotifications } from '../services/in-app-notification'
import { sendAnnouncementEmail } from '@/lib/email'
import {
openWindow,
closeWindow,
@@ -255,19 +257,43 @@ export const roundRouter = router({
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds, autoPassPending } = input
// Get current round with competition context
// Get current round with competition context + status
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
})
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
})
}
// Determine target round
let targetRound: { id: string; name: string }
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
if (targetRoundId) {
targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
select: { id: true, name: true },
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
})
// Validate: target must be in same competition
if (targetRound.competitionId !== currentRound.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target round must belong to the same competition as the source round.',
})
}
// Validate: target must be after current round
if (targetRound.sortOrder <= currentRound.sortOrder) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target round must come after the current round (higher sortOrder).',
})
}
} else {
// Find next round in same competition by sortOrder
const nextRound = await ctx.prisma.round.findFirst({
@@ -276,7 +302,7 @@ export const roundRouter = router({
sortOrder: { gt: currentRound.sortOrder },
},
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
})
if (!nextRound) {
throw new TRPCError({
@@ -287,35 +313,50 @@ export const roundRouter = router({
targetRound = nextRound
}
// Auto-pass all PENDING projects first (for intake/bulk workflows)
let autoPassedCount = 0
if (autoPassPending) {
const result = await ctx.prisma.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance
let idsToAdvance: string[]
// Validate projectIds exist in current round if provided
if (projectIds && projectIds.length > 0) {
idsToAdvance = projectIds
} else {
// Default: all PASSED projects in current round
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'PASSED' },
const existingStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, projectId: { in: projectIds } },
select: { projectId: true },
})
idsToAdvance = passedStates.map((s) => s.projectId)
const existingIds = new Set(existingStates.map((s) => s.projectId))
const missing = projectIds.filter((id) => !existingIds.has(id))
if (missing.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Projects not found in current round: ${missing.join(', ')}`,
})
}
}
if (idsToAdvance.length === 0) {
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
}
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
let autoPassedCount = 0
let idsToAdvance: string[]
// Transaction: create entries in target round + mark current as COMPLETED
await ctx.prisma.$transaction(async (tx) => {
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
if (autoPassPending) {
const result = await tx.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance
if (projectIds && projectIds.length > 0) {
idsToAdvance = projectIds
} else {
// Default: all PASSED projects in current round
const passedStates = await tx.projectRoundState.findMany({
where: { roundId, state: 'PASSED' },
select: { projectId: true },
})
idsToAdvance = passedStates.map((s) => s.projectId)
}
if (idsToAdvance.length === 0) return
// Create ProjectRoundState in target round
await tx.projectRoundState.createMany({
data: idsToAdvance.map((projectId) => ({
@@ -351,6 +392,12 @@ export const roundRouter = router({
})
})
// If nothing to advance (set inside tx), return early
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!idsToAdvance! || idsToAdvance!.length === 0) {
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
}
// Audit
await logAudit({
prisma: ctx.prisma,
@@ -362,16 +409,105 @@ export const roundRouter = router({
fromRound: currentRound.name,
toRound: targetRound.name,
targetRoundId: targetRound.id,
projectCount: idsToAdvance.length,
projectCount: idsToAdvance!.length,
autoPassedCount,
projectIds: idsToAdvance,
projectIds: idsToAdvance!,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Fix 5: notifyOnEntry — notify team members when projects enter target round
try {
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
if (targetConfig.notifyOnEntry) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: idsToAdvance! } },
select: { userId: true },
})
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
if (userIds.length > 0) {
void createBulkNotifications({
userIds,
type: 'round_entry',
title: `Projects entered: ${targetRound.name}`,
message: `Your project has been advanced to the round "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'ArrowRight',
})
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
}
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
try {
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
if (sourceConfig.notifyOnAdvance) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: idsToAdvance! } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
// Collect unique user IDs for in-app notifications
const applicantUserIds = new Set<string>()
for (const project of projects) {
for (const tm of project.teamMembers) {
applicantUserIds.add(tm.user.id)
}
}
if (applicantUserIds.size > 0) {
void createBulkNotifications({
userIds: [...applicantUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Send emails to team members (fire-and-forget)
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
void sendAnnouncementEmail(
email,
name,
`Your project has advanced to: ${targetRound.name}`,
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
})
}
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
}
return {
advancedCount: idsToAdvance.length,
advancedCount: idsToAdvance!.length,
autoPassedCount,
targetRoundId: targetRound.id,
targetRoundName: targetRound.name,