diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
index 74ca42f..474c895 100644
--- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
@@ -101,6 +101,12 @@ import {
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
@@ -116,6 +122,20 @@ import { motion } from 'motion/react'
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
import type { Criterion } from '@/components/forms/evaluation-form-builder'
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function getRelativeTime(date: Date): string {
+ const now = new Date()
+ const diffMs = date.getTime() - now.getTime()
+ const absDiffMs = Math.abs(diffMs)
+ const minutes = Math.floor(absDiffMs / 60_000)
+ const hours = Math.floor(absDiffMs / 3_600_000)
+ const days = Math.floor(absDiffMs / 86_400_000)
+
+ const label = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
+ return diffMs > 0 ? `in ${label}` : `${label} ago`
+}
+
// ── Status & type config maps ──────────────────────────────────────────────
const roundStatusConfig = {
ROUND_DRAFT: {
@@ -203,6 +223,10 @@ export default function RoundDetailPage() {
const [newJuryName, setNewJuryName] = useState('')
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
+ const [editingName, setEditingName] = useState(false)
+ const [nameValue, setNameValue] = useState('')
+ const nameInputRef = useRef(null)
+ const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
const utils = trpc.useUtils()
@@ -565,77 +589,164 @@ export default function RoundDetailPage() {
-
+
+ {round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}
-
{round.name}
+ {/* 4.6 Inline-editable round name */}
+ {editingName ? (
+
setNameValue(e.target.value)}
+ onBlur={() => {
+ const trimmed = nameValue.trim()
+ if (trimmed && trimmed !== round.name) {
+ updateMutation.mutate({ id: roundId, name: trimmed })
+ }
+ setEditingName(false)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ (e.target as HTMLInputElement).blur()
+ }
+ if (e.key === 'Escape') {
+ setNameValue(round.name)
+ setEditingName(false)
+ }
+ }}
+ className="text-xl font-bold tracking-tight bg-white/10 border-white/30 text-white h-8 w-64"
+ autoFocus
+ />
+ ) : (
+
{
+ setNameValue(round.name)
+ setEditingName(true)
+ setTimeout(() => nameInputRef.current?.focus(), 0)
+ }}
+ title="Click to edit round name"
+ >
+ {round.name}
+
+ )}
{typeCfg.label}
- {/* Status dropdown */}
-
-
-
-
- {statusCfg.label}
-
-
-
-
- {status === 'ROUND_DRAFT' && (
- activateMutation.mutate({ roundId })}
- disabled={isTransitioning}
+ {/* Status dropdown with confirmation dialogs (4.1) */}
+
+
+
+
+
+
+
+
+ {statusCfg.label}
+
+
+
+
+ {status === 'ROUND_DRAFT' && (
+ setStatusConfirmAction('activate')}
+ disabled={isTransitioning}
+ >
+
+ Activate Round
+
+ )}
+ {status === 'ROUND_ACTIVE' && (
+ setStatusConfirmAction('close')}
+ disabled={isTransitioning}
+ >
+
+ Close Round
+
+ )}
+ {status === 'ROUND_CLOSED' && (
+ <>
+ setStatusConfirmAction('reopen')}
+ disabled={isTransitioning}
+ >
+
+ Reopen Round
+
+
+ setStatusConfirmAction('archive')}
+ disabled={isTransitioning}
+ >
+
+ Archive Round
+
+ >
+ )}
+ {isTransitioning && (
+
+
+ Updating...
+
+ )}
+
+
+
+
+
+ {statusCfg.description}
+
+
+
+
+ {/* Status change confirmation dialog (4.1) */}
+ { if (!open) setStatusConfirmAction(null) }}>
+
+
+
+ {statusConfirmAction === 'activate' && 'Activate this round?'}
+ {statusConfirmAction === 'close' && 'Close this round?'}
+ {statusConfirmAction === 'reopen' && 'Reopen this round?'}
+ {statusConfirmAction === 'archive' && 'Archive this round?'}
+
+
+ {statusConfirmAction === 'activate' && 'The round will go live. Projects can be processed and jury members will be able to see their assignments.'}
+ {statusConfirmAction === 'close' && 'No further changes will be accepted. You can reactivate later if needed.'}
+ {statusConfirmAction === 'reopen' && 'The round will become active again. Any rounds after this one that are currently active will be paused automatically.'}
+ {statusConfirmAction === 'archive' && 'The round will be archived. It will only be available as a historical record.'}
+
+
+
+ Cancel
+ {
+ if (statusConfirmAction === 'activate') activateMutation.mutate({ roundId })
+ else if (statusConfirmAction === 'close') closeMutation.mutate({ roundId })
+ else if (statusConfirmAction === 'reopen') reopenMutation.mutate({ roundId })
+ else if (statusConfirmAction === 'archive') archiveMutation.mutate({ roundId })
+ setStatusConfirmAction(null)
+ }}
>
-
- Activate Round
-
- )}
- {status === 'ROUND_ACTIVE' && (
- closeMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Close Round
-
- )}
- {status === 'ROUND_CLOSED' && (
- <>
- reopenMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Reopen Round
-
-
- archiveMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Archive Round
-
- >
- )}
- {isTransitioning && (
-
-
- Updating...
-
- )}
-
-
+ {statusConfirmAction === 'activate' && 'Activate'}
+ {statusConfirmAction === 'close' && 'Close Round'}
+ {statusConfirmAction === 'reopen' && 'Reopen'}
+ {statusConfirmAction === 'archive' && 'Archive'}
+
+
+
+
{typeCfg.description}
@@ -694,38 +805,30 @@ export default function RoundDetailPage() {
Jury
- {juryGroups && juryGroups.length > 0 ? (
- {
- assignJuryMutation.mutate({
- id: roundId,
- juryGroupId: value === '__none__' ? null : value,
- })
- }}
- disabled={assignJuryMutation.isPending}
- >
-
-
-
-
- No jury assigned
- {juryGroups.map((jg: any) => (
-
- {jg.name} ({jg._count?.members ?? 0} members)
-
- ))}
-
-
- ) : juryGroup ? (
+ {juryGroup ? (
<>
{juryMemberCount}
- {juryGroup.name}
+
+
{juryGroup.name}
+
setActiveTab('jury')}
+ >
+ Change
+
+
>
) : (
<>
—
- No jury groups yet
+ setActiveTab('jury')}
+ >
+ Assign jury group
+
>
)}
@@ -755,6 +858,21 @@ export default function RoundDetailPage() {
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
: 'No deadline'}
+ {(() => {
+ const now = new Date()
+ const openAt = round.windowOpenAt ? new Date(round.windowOpenAt) : null
+ const closeAt = round.windowCloseAt ? new Date(round.windowCloseAt) : null
+ if (openAt && now < openAt) {
+ return Opens {getRelativeTime(openAt)}
+ }
+ if (closeAt && now < closeAt) {
+ return Closes {getRelativeTime(closeAt)}
+ }
+ if (closeAt && now >= closeAt) {
+ return Closed {getRelativeTime(closeAt)}
+ }
+ return null
+ })()}
>
) : (
<>
@@ -885,7 +1003,7 @@ export default function RoundDetailPage() {
)}
-
+
{item.label}
{item.detail}
@@ -1325,7 +1443,7 @@ export default function RoundDetailPage() {
{[
{ label: 'Type', value:
{typeCfg.label} },
{ label: 'Status', value:
{statusCfg.label} },
- { label: 'Sort Order', value:
{round.sortOrder} },
+ { label: 'Position', value:
{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`} },
...(round.purposeKey ? [{ label: 'Purpose', value:
{round.purposeKey} }] : []),
{ label: 'Jury Group', value:
{juryGroup ? juryGroup.name : '\u2014'} },
{ label: 'Opens', value:
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'} },
@@ -1653,156 +1771,189 @@ export default function RoundDetailPage() {
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
- {/* Coverage Report */}
-
-
- {/* Generate Assignments */}
-
-
-
-
-
- Assignment Generation
- {aiAssignmentMutation.isPending && (
-
-
- AI generating...
-
- )}
- {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
-
-
- {aiAssignmentMutation.data.stats.assignmentsGenerated} ready
-
- )}
-
-
- AI-suggested jury-to-project assignments based on expertise and workload
-
+ {/* 4.9 Gate assignments when no jury group */}
+ {!round?.juryGroupId ? (
+
+
+
+
-
- {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
+
No Jury Group Assigned
+
+ Assign a jury group first to manage assignments.
+
+
setActiveTab('jury')}>
+
+ Go to Jury Tab
+
+
+
+ ) : (
+ <>
+ {/* Card 1: Coverage & Generation */}
+
+
+ Coverage & Generation
+ Assignment coverage overview and AI generation
+
+
+
+
+ {/* Generate Assignments */}
+
+
+
+
+ Assignment Generation
+ {aiAssignmentMutation.isPending && (
+
+
+ AI generating...
+
+ )}
+ {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
+
+
+ {aiAssignmentMutation.data.stats.assignmentsGenerated} ready
+
+ )}
+
+
+ AI-suggested jury-to-project assignments based on expertise and workload
+
+
+
+ {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
+ setPreviewSheetOpen(true)}
+ >
+ Review Assignments
+
+ )}
setPreviewSheetOpen(true)}
+ onClick={() => {
+ aiAssignmentMutation.mutate({
+ roundId,
+ requiredReviews: (config.requiredReviewsPerProject as number) || 3,
+ })
+ }}
+ disabled={projectCount === 0 || !juryGroup || aiAssignmentMutation.isPending}
>
- Review Assignments
+ {aiAssignmentMutation.isPending ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ {aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}
+ >
+ )}
- )}
- {
- aiAssignmentMutation.mutate({
- roundId,
- requiredReviews: (config.requiredReviewsPerProject as number) || 3,
- })
- }}
- disabled={projectCount === 0 || !juryGroup || aiAssignmentMutation.isPending}
- >
- {aiAssignmentMutation.isPending ? (
- <>
-
- Generating...
- >
- ) : (
- <>
-
- {aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}
- >
- )}
-
+
+ {projectCount === 0 && (
+
+
+ Add projects to this round first.
+
+ )}
+ {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (
+
+ Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.
+
+ )}
+ {aiAssignmentMutation.isPending && (
+
+
+
+
AI is analyzing projects and jurors...
+
+ Matching expertise, reviewing bios, and balancing workloads
+
+
+
+ )}
+ {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
+
+
+
+
+ AI generation failed
+
+
+ {aiAssignmentMutation.error.message}
+
+
+
+ )}
+ {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
+
+
+
+
+ {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
+
+
+ {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
+ {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
+
+
+
setPreviewSheetOpen(true)}>
+ Review & Execute
+
+
+ )}
-
-
- {!juryGroup && (
-
-
- Assign a jury group first before generating assignments.
-
- )}
- {projectCount === 0 && (
-
-
- Add projects to this round first.
-
- )}
- {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (
-
- Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.
-
- )}
- {aiAssignmentMutation.isPending && (
-
-
-
-
AI is analyzing projects and jurors...
-
- Matching expertise, reviewing bios, and balancing workloads
-
-
-
- )}
- {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
-
-
-
-
- AI generation failed
-
-
- {aiAssignmentMutation.error.message}
-
-
-
- )}
- {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
-
-
-
-
- {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
-
-
- {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
- {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
-
-
-
setPreviewSheetOpen(true)}>
- Review & Execute
-
-
- )}
- {/* Jury Progress + Score Distribution */}
+ {/* Jury Progress + Score Distribution (standalone 2-col grid) */}
- {/* Actions: Send Reminders + Notify + Export */}
-
-
-
- setExportOpen(true)}>
-
- Export Evaluations
-
-
+ {/* Card 2: Assignments — with action buttons in header */}
+
+
+
+
+ Assignments
+ Individual jury-project assignments and actions
+
+
+
+
+ setExportOpen(true)}>
+
+ Export
+
+
+
+
+
+
+
+
- {/* Individual Assignments Table */}
-
-
- {/* Conflict of Interest Declarations */}
-
-
- {/* Unassigned Queue */}
-
+ {/* Card 3: Monitoring — COI + Unassigned Queue */}
+
+
+ Monitoring
+ Conflict of interest declarations and unassigned projects
+
+
+
+
+
+
{/* Assignment Preview Sheet */}
+ >
+ )}
)}
@@ -1840,7 +1993,7 @@ export default function RoundDetailPage() {
Start Date
updateMutation.mutate({ id: roundId, windowOpenAt: date })}
+ onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date }, { onSuccess: () => toast.success('Dates saved') })}
placeholder="Select start date & time"
clearable
/>
@@ -1849,7 +2002,7 @@ export default function RoundDetailPage() {
End Date
updateMutation.mutate({ id: roundId, windowCloseAt: date })}
+ onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date }, { onSuccess: () => toast.success('Dates saved') })}
placeholder="Select end date & time"
clearable
/>
@@ -2140,7 +2293,7 @@ function InlineMemberCap({
>
max:
{currentValue ?? '\u221E'}
-
+
)
}
@@ -2154,12 +2307,12 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
)
return (
-
-
- Unassigned Projects
- Projects with fewer than {requiredReviews} jury assignments
-
-
+
+
+
Unassigned Projects
+
Projects with fewer than {requiredReviews} jury assignments
+
+
{isLoading ? (
{[1, 2, 3].map((i) => )}
@@ -2197,8 +2350,8 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
All projects have sufficient assignments
)}
-
-
+
+
)
}
@@ -2364,7 +2517,7 @@ function NotifyJurorsButton({ roundId }: { roundId: string }) {
const [open, setOpen] = useState(false)
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
onSuccess: (data) => {
- toast.success(`Notified ${data.jurorCount} juror(s) — ${data.emailsSent} email(s) sent`)
+ toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
setOpen(false)
},
onError: (err) => toast.error(err.message),
@@ -2448,10 +2601,18 @@ function IndividualAssignmentsTable({
projectStates: any[] | undefined
}) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
+ const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
+ const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
+ // ── By Juror mode state ──
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState
>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
+ // ── By Project mode state ──
+ const [selectedProjectId, setSelectedProjectId] = useState('')
+ const [selectedJurorIds, setSelectedJurorIds] = useState>(new Set())
+ const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
+ const [jurorSearch, setJurorSearch] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@@ -2505,9 +2666,13 @@ function IndividualAssignmentsTable({
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
+ setAssignMode('byJuror')
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
+ setSelectedProjectId('')
+ setSelectedJurorIds(new Set())
+ setJurorSearch('')
}, [])
const selectedJuror = useMemo(
@@ -2580,23 +2745,81 @@ function IndividualAssignmentsTable({
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
+ // ── By Project mode helpers ──
+
+ // Existing assignments for the selected project (to grey out already-assigned jurors)
+ const projectExistingJurorIds = useMemo(() => {
+ if (!selectedProjectId || !assignments) return new Set()
+ return new Set(
+ assignments
+ .filter((a: any) => a.projectId === selectedProjectId)
+ .map((a: any) => a.userId)
+ )
+ }, [selectedProjectId, assignments])
+
+ // Count assignments per juror in this round (for display)
+ const jurorAssignmentCounts = useMemo(() => {
+ if (!assignments) return new Map()
+ const counts = new Map()
+ for (const a of assignments) {
+ counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
+ }
+ return counts
+ }, [assignments])
+
+ // Filter jurors by search term
+ const filteredJurors = useMemo(() => {
+ const items = juryMembers ?? []
+ if (!jurorSearch) return items
+ const q = jurorSearch.toLowerCase()
+ return items.filter((j: any) =>
+ j.name?.toLowerCase().includes(q) ||
+ j.email?.toLowerCase().includes(q)
+ )
+ }, [juryMembers, jurorSearch])
+
+ const toggleJuror = useCallback((jurorId: string) => {
+ setSelectedJurorIds(prev => {
+ const next = new Set(prev)
+ if (next.has(jurorId)) next.delete(jurorId)
+ else next.add(jurorId)
+ return next
+ })
+ }, [])
+
+ const handleCreateByProject = useCallback(() => {
+ if (!selectedProjectId || selectedJurorIds.size === 0) return
+
+ const jurorIds = Array.from(selectedJurorIds)
+ if (jurorIds.length === 1) {
+ createMutation.mutate({
+ userId: jurorIds[0],
+ projectId: selectedProjectId,
+ roundId,
+ })
+ } else {
+ bulkCreateMutation.mutate({
+ roundId,
+ assignments: jurorIds.map(userId => ({
+ userId,
+ projectId: selectedProjectId,
+ })),
+ })
+ }
+ }, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
+
return (
-
-
-
-
- All Assignments
-
- {assignments?.length ?? 0} individual jury-project assignments
-
-
-
setAddDialogOpen(true)}>
-
- Add
-
+
+
+
+
{assignments?.length ?? 0} individual assignments
-
-
+ setAddDialogOpen(true)}>
+
+ Add
+
+
+
{isLoading ? (
{[1, 2, 3, 4, 5].map((i) => )}
@@ -2646,11 +2869,7 @@ function IndividualAssignmentsTable({
{a.evaluation && (
<>
{
- if (confirm(`Reset evaluation by ${a.user?.name || a.user?.email} for "${a.project?.title}"? This will erase all scores and feedback so they can start over.`)) {
- resetEvalMutation.mutate({ assignmentId: a.id })
- }
- }}
+ onClick={() => setConfirmAction({ type: 'reset', assignment: a })}
disabled={resetEvalMutation.isPending}
>
@@ -2661,11 +2880,7 @@ function IndividualAssignmentsTable({
)}
{
- if (confirm(`Remove assignment for ${a.user?.name || a.user?.email} on "${a.project?.title}"?`)) {
- deleteMutation.mutate({ id: a.id })
- }
- }}
+ onClick={() => setConfirmAction({ type: 'delete', assignment: a })}
disabled={deleteMutation.isPending}
>
@@ -2677,7 +2892,7 @@ function IndividualAssignmentsTable({
))}
)}
-
+
{/* Add Assignment Dialog */}
{
@@ -2688,217 +2903,451 @@ function IndividualAssignmentsTable({
Add Assignment
- Select a juror and one or more projects to assign
+ {assignMode === 'byJuror'
+ ? 'Select a juror, then choose projects to assign'
+ : 'Select a project, then choose jurors to assign'
+ }
-
- {/* Juror Selector */}
-
-
Juror
-
-
-
- {selectedJuror
- ? (
-
- {selectedJuror.name || selectedJuror.email}
-
- {selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
-
-
- )
- : Select a jury member...
- }
-
-
-
-
-
-
-
- No jury members found.
-
- {juryMembers?.map((juror: any) => {
- const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
- return (
- {
- setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
- setSelectedProjectIds(new Set())
- setJurorPopoverOpen(false)
- }}
- >
-
-
-
-
- {juror.name || 'Unnamed'}
-
-
- {juror.email}
-
-
-
- {juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
- {atCapacity ? ' full' : ''}
-
-
-
- )
- })}
-
-
-
-
-
-
+ {/* Mode Toggle */}
+
{
+ setAssignMode(v as 'byJuror' | 'byProject')
+ // Reset selections when switching
+ setSelectedJurorId('')
+ setSelectedProjectIds(new Set())
+ setProjectSearch('')
+ setSelectedProjectId('')
+ setSelectedJurorIds(new Set())
+ setJurorSearch('')
+ }}>
+
+ By Juror
+ By Project
+
- {/* Project Multi-Select */}
-
-
-
- Projects
- {selectedProjectIds.size > 0 && (
-
- ({selectedProjectIds.size} selected)
-
- )}
-
- {selectedJurorId && (
-
+ {/* ── By Juror Tab ── */}
+
+ {/* Juror Selector */}
+
+
Juror
+
+
- Select all
+ {selectedJuror
+ ? (
+
+ {selectedJuror.name || selectedJuror.email}
+
+ {selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
+
+
+ )
+ : Select a jury member...
+ }
+
+
+
+
+
+
+ No jury members found.
+
+ {juryMembers?.map((juror: any) => {
+ const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
+ return (
+ {
+ setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
+ setSelectedProjectIds(new Set())
+ setJurorPopoverOpen(false)
+ }}
+ >
+
+
+
+
+ {juror.name || 'Unnamed'}
+
+
+ {juror.email}
+
+
+
+ {juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
+ {atCapacity ? ' full' : ''}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ {/* Project Multi-Select */}
+
+
+
+ Projects
{selectedProjectIds.size > 0 && (
+
+ ({selectedProjectIds.size} selected)
+
+ )}
+
+ {selectedJurorId && (
+
setSelectedProjectIds(new Set())}
+ onClick={selectAllUnassigned}
>
- Clear
+ Select all
- )}
-
- )}
-
-
- {/* Search input */}
-
-
- setProjectSearch(e.target.value)}
- className="pl-9 h-9"
- />
-
-
- {/* Project checklist */}
-
-
- {!selectedJurorId ? (
-
- Select a juror first
-
- ) : filteredProjects.length === 0 ? (
-
- No projects found
-
- ) : (
- filteredProjects.map((ps: any) => {
- const project = ps.project
- if (!project) return null
- const alreadyAssigned = jurorExistingProjectIds.has(project.id)
- const isSelected = selectedProjectIds.has(project.id)
-
- return (
-
0 && (
+ setSelectedProjectIds(new Set())}
>
- toggleProject(project.id)}
- />
-
-
{project.title}
-
- {project.competitionCategory && (
-
- {project.competitionCategory === 'STARTUP'
- ? 'Startup'
- : project.competitionCategory === 'BUSINESS_CONCEPT'
- ? 'Concept'
- : project.competitionCategory}
-
- )}
- {alreadyAssigned && (
-
- Assigned
-
- )}
-
-
-
- )
- })
+ Clear
+
+ )}
+
)}
-
-
-
-
-
- Cancel
-
-
- {isMutating && }
- {selectedProjectIds.size <= 1
- ? 'Create Assignment'
- : `Create ${selectedProjectIds.size} Assignments`
- }
-
-
+ {/* Search input */}
+
+
+ setProjectSearch(e.target.value)}
+ className="pl-9 h-9"
+ />
+
+
+ {/* Project checklist */}
+
+
+ {!selectedJurorId ? (
+
+ Select a juror first
+
+ ) : filteredProjects.length === 0 ? (
+
+ No projects found
+
+ ) : (
+ filteredProjects.map((ps: any) => {
+ const project = ps.project
+ if (!project) return null
+ const alreadyAssigned = jurorExistingProjectIds.has(project.id)
+ const isSelected = selectedProjectIds.has(project.id)
+
+ return (
+
+ toggleProject(project.id)}
+ />
+
+
{project.title}
+
+ {project.competitionCategory && (
+
+ {project.competitionCategory === 'STARTUP'
+ ? 'Startup'
+ : project.competitionCategory === 'BUSINESS_CONCEPT'
+ ? 'Concept'
+ : project.competitionCategory}
+
+ )}
+ {alreadyAssigned && (
+
+ Assigned
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+ {isMutating && }
+ {selectedProjectIds.size <= 1
+ ? 'Create Assignment'
+ : `Create ${selectedProjectIds.size} Assignments`
+ }
+
+
+
+
+ {/* ── By Project Tab ── */}
+
+ {/* Project Selector */}
+
+
Project
+
+
+
+ {selectedProjectId
+ ? (
+
+ {(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
+
+ )
+ : Select a project...
+ }
+
+
+
+
+
+
+
+ No projects found.
+
+ {(projectStates ?? []).map((ps: any) => {
+ const project = ps.project
+ if (!project) return null
+ return (
+ {
+ setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
+ setSelectedJurorIds(new Set())
+ setProjectPopoverOpen(false)
+ }}
+ >
+
+
+
+
{project.title}
+
{project.teamName}
+
+ {project.competitionCategory && (
+
+ {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ {/* Juror Multi-Select */}
+
+
+
+ Jurors
+ {selectedJurorIds.size > 0 && (
+
+ ({selectedJurorIds.size} selected)
+
+ )}
+
+ {selectedProjectId && selectedJurorIds.size > 0 && (
+ setSelectedJurorIds(new Set())}
+ >
+ Clear
+
+ )}
+
+
+ {/* Search input */}
+
+
+ setJurorSearch(e.target.value)}
+ className="pl-9 h-9"
+ />
+
+
+ {/* Juror checklist */}
+
+
+ {!selectedProjectId ? (
+
+ Select a project first
+
+ ) : filteredJurors.length === 0 ? (
+
+ No jurors found
+
+ ) : (
+ filteredJurors.map((juror: any) => {
+ const alreadyAssigned = projectExistingJurorIds.has(juror.id)
+ const isSelected = selectedJurorIds.has(juror.id)
+ const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
+
+ return (
+
+ toggleJuror(juror.id)}
+ />
+
+
+ {juror.name || 'Unnamed'}
+ {juror.email}
+
+
+
+ {assignCount} assigned
+
+ {alreadyAssigned && (
+
+ Already on project
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+ {isMutating && }
+ {selectedJurorIds.size <= 1
+ ? 'Create Assignment'
+ : `Create ${selectedJurorIds.size} Assignments`
+ }
+
+
+
+
-
+
+ {/* 4.2 Confirmation AlertDialog for reset/delete (replaces native confirm) */}
+
{ if (!open) setConfirmAction(null) }}>
+
+
+
+ {confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
+
+
+ {confirmAction?.type === 'reset'
+ ? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
+ : `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
+ }
+
+
+
+ Cancel
+ {
+ if (confirmAction?.type === 'reset') {
+ resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
+ } else if (confirmAction?.type === 'delete') {
+ deleteMutation.mutate({ id: confirmAction.assignment.id })
+ }
+ setConfirmAction(null)
+ }}
+ >
+ {confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
+
+
+
+
+
)
}
@@ -3149,7 +3598,7 @@ function AdvanceProjectsDialog({
{isSimpleAdvance ? (
handleAdvance(true)}
- disabled={totalProjectCount === 0 || advanceMutation.isPending}
+ disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
>
{advanceMutation.isPending && }
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
@@ -3157,7 +3606,7 @@ function AdvanceProjectsDialog({
) : (
handleAdvance()}
- disabled={selected.size === 0 || advanceMutation.isPending}
+ disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
>
{advanceMutation.isPending && }
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
@@ -3517,36 +3966,38 @@ function COIReviewSection({ roundId }: { roundId: string }) {
onError: (err) => toast.error(err.message),
})
- // Don't show section if no declarations
+ // Show placeholder when no declarations
if (!isLoading && (!declarations || declarations.length === 0)) {
- return null
+ return (
+
+
No conflict of interest declarations yet.
+
+ )
}
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
return (
-
-
-
-
-
-
- Conflict of Interest Declarations
-
-
- {declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
- {conflictCount > 0 && (
- <> — {conflictCount} conflict{conflictCount !== 1 ? 's' : ''} >
- )}
- {unreviewedCount > 0 && (
- <> ({unreviewedCount} pending review)>
- )}
-
-
+
+
+
+
+
+ Conflict of Interest Declarations
+
+
+ {declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
+ {conflictCount > 0 && (
+ <> — {conflictCount} conflict{conflictCount !== 1 ? 's' : ''} >
+ )}
+ {unreviewedCount > 0 && (
+ <> ({unreviewedCount} pending review)>
+ )}
+
-
-
+
+
{isLoading ? (
{[1, 2, 3].map((i) => )}
@@ -3640,7 +4091,7 @@ function COIReviewSection({ roundId }: { roundId: string }) {
))}
)}
-
-
+
+
)
}
diff --git a/src/components/admin/round/project-states-table.tsx b/src/components/admin/round/project-states-table.tsx
index 71bd251..525e4cb 100644
--- a/src/components/admin/round/project-states-table.tsx
+++ b/src/components/admin/round/project-states-table.tsx
@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const [removeConfirmId, setRemoveConfirmId] = useState
(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
+ const [addProjectOpen, setAddProjectOpen] = useState(false)
const utils = trpc.useUtils()
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
-
{ setQuickAddOpen(true) }}>
+ { setAddProjectOpen(true) }}>
- Quick Add
+ Add Project
-
-
-
- Add from Pool
-
-
@@ -436,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
)}
- {/* Quick Add Dialog */}
+ {/* Quick Add Dialog (legacy, kept for empty state) */}
+ {/* Add Project Dialog (Create New + From Pool) */}
+ {
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ }}
+ />
+
{/* Single Remove Confirmation */}
{ if (!open) setRemoveConfirmId(null) }}>
@@ -673,3 +682,287 @@ function QuickAddDialog({
)
}
+
+/**
+ * Add Project Dialog — two tabs: "Create New" and "From Pool".
+ * Create New: form to create a project and assign it directly to the round.
+ * From Pool: search existing projects not yet in this round and assign them.
+ */
+function AddProjectDialog({
+ open,
+ onOpenChange,
+ roundId,
+ competitionId,
+ onAssigned,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ roundId: string
+ competitionId: string
+ onAssigned: () => void
+}) {
+ const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
+
+ // ── Create New tab state ──
+ const [title, setTitle] = useState('')
+ const [teamName, setTeamName] = useState('')
+ const [description, setDescription] = useState('')
+ const [country, setCountry] = useState('')
+ const [category, setCategory] = useState('')
+
+ // ── From Pool tab state ──
+ const [poolSearch, setPoolSearch] = useState('')
+ const [selectedPoolIds, setSelectedPoolIds] = useState>(new Set())
+
+ const utils = trpc.useUtils()
+
+ // Get the competition to find programId (for pool search)
+ const { data: competition } = trpc.competition.getById.useQuery(
+ { id: competitionId },
+ { enabled: open && !!competitionId },
+ )
+ const programId = (competition as any)?.programId || ''
+
+ // Pool query
+ const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
+ {
+ programId,
+ excludeRoundId: roundId,
+ search: poolSearch.trim() || undefined,
+ perPage: 50,
+ },
+ { enabled: open && activeTab === 'pool' && !!programId },
+ )
+
+ // Create mutation
+ const createMutation = trpc.project.createAndAssignToRound.useMutation({
+ onSuccess: () => {
+ toast.success('Project created and added to round')
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ onAssigned()
+ resetAndClose()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Assign from pool mutation
+ const assignMutation = trpc.projectPool.assignToRound.useMutation({
+ onSuccess: (data) => {
+ toast.success(`${data.assignedCount} project(s) added to round`)
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ onAssigned()
+ resetAndClose()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const resetAndClose = () => {
+ setTitle('')
+ setTeamName('')
+ setDescription('')
+ setCountry('')
+ setCategory('')
+ setPoolSearch('')
+ setSelectedPoolIds(new Set())
+ onOpenChange(false)
+ }
+
+ const handleCreate = () => {
+ if (!title.trim()) return
+ createMutation.mutate({
+ title: title.trim(),
+ teamName: teamName.trim() || undefined,
+ description: description.trim() || undefined,
+ country: country.trim() || undefined,
+ competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
+ roundId,
+ })
+ }
+
+ const handleAssignFromPool = () => {
+ if (selectedPoolIds.size === 0) return
+ assignMutation.mutate({
+ projectIds: Array.from(selectedPoolIds),
+ roundId,
+ })
+ }
+
+ const togglePoolProject = (id: string) => {
+ setSelectedPoolIds(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const isMutating = createMutation.isPending || assignMutation.isPending
+
+ return (
+ {
+ if (!isOpen) resetAndClose()
+ else onOpenChange(true)
+ }}>
+
+
+ Add Project to Round
+
+ Create a new project or select existing ones to add to this round.
+
+
+
+ setActiveTab(v as 'create' | 'pool')}>
+
+ Create New
+ From Pool
+
+
+ {/* ── Create New Tab ── */}
+
+
+ Title *
+ setTitle(e.target.value)}
+ />
+
+
+ Team Name
+ setTeamName(e.target.value)}
+ />
+
+
+
+ Country
+ setCountry(e.target.value)}
+ />
+
+
+ Category
+
+
+
+
+
+ Startup
+ Business Concept
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ />
+
+
+
+ Cancel
+
+ {createMutation.isPending && }
+ Create & Add to Round
+
+
+
+
+ {/* ── From Pool Tab ── */}
+
+
+
+ setPoolSearch(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+ {poolLoading && (
+
+
+
+ )}
+
+ {!poolLoading && poolResults?.projects.length === 0 && (
+
+ {poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
+
+ )}
+
+ {poolResults?.projects.map((project: any) => {
+ const isSelected = selectedPoolIds.has(project.id)
+ return (
+
+ togglePoolProject(project.id)}
+ />
+
+
+
{project.title}
+
+ {project.teamName}
+ {project.country && <> · {project.country}>}
+
+
+ {project.competitionCategory && (
+
+ {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
+
+ )}
+
+
+ )
+ })}
+
+
+
+ {poolResults && poolResults.total > 50 && (
+
+ Showing 50 of {poolResults.total} — refine your search for more specific results
+
+ )}
+
+
+ Cancel
+
+ {assignMutation.isPending && }
+ {selectedPoolIds.size <= 1
+ ? 'Add to Round'
+ : `Add ${selectedPoolIds.size} Projects to Round`
+ }
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 574ae16..36809bc 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -1083,6 +1083,60 @@ Together for a healthier ocean.
}
}
+/**
+ * Generate "3 Days Remaining" email template (for jury)
+ */
+function getReminder3DaysTemplate(
+ name: string,
+ pendingCount: number,
+ roundName: string,
+ deadline: string,
+ assignmentsUrl?: string
+): EmailTemplate {
+ const greeting = name ? `Hello ${name},` : 'Hello,'
+
+ const urgentBox = `
+
+
+
+ ⚠ 3 Days Remaining
+
+
+
+`
+
+ const content = `
+ ${sectionTitle(greeting)}
+ ${urgentBox}
+ ${paragraph(`This is a reminder that ${roundName} closes in 3 days.`)}
+ ${statCard('Pending Evaluations', pendingCount)}
+ ${infoBox(`Deadline: ${deadline}`, 'warning')}
+ ${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
+ ${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
+ `
+
+ return {
+ subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
+ html: getEmailWrapper(content),
+ text: `
+${greeting}
+
+This is a reminder that ${roundName} closes in 3 days.
+
+You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
+Deadline: ${deadline}
+
+Please plan to complete your remaining evaluations before the deadline.
+
+${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
+
+---
+Monaco Ocean Protection Challenge
+Together for a healthier ocean.
+ `,
+ }
+}
+
/**
* Generate "1 Hour Reminder" email template (for jury)
*/
@@ -1457,6 +1511,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record = {
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
+ REMINDER_3_DAYS: (ctx) =>
+ getReminder3DaysTemplate(
+ ctx.name || '',
+ (ctx.metadata?.pendingCount as number) || 0,
+ (ctx.metadata?.roundName as string) || 'this round',
+ (ctx.metadata?.deadline as string) || 'Soon',
+ ctx.linkUrl
+ ),
REMINDER_24H: (ctx) =>
getReminder24HTemplate(
ctx.name || '',
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts
index 398ac5d..5d76735 100644
--- a/src/server/routers/assignment.ts
+++ b/src/server/routers/assignment.ts
@@ -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
- 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
- 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
- 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 }
}),
})
diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts
index a7961d4..f78b7a1 100644
--- a/src/server/routers/evaluation.ts
+++ b/src/server/routers/evaluation.ts
@@ -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) || {}
+ 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 | 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) || {}
- 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) || {}
- 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) || {}
- 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',
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index 516a301..ac60da4 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -1249,4 +1249,97 @@ export const projectRouter = router({
stats,
}
}),
+
+ /**
+ * Create a new project and assign it directly to a round.
+ * Used for late-arriving projects that need to enter a specific round immediately.
+ */
+ createAndAssignToRound: adminProcedure
+ .input(
+ z.object({
+ title: z.string().min(1).max(500),
+ teamName: z.string().optional(),
+ description: z.string().optional(),
+ country: z.string().optional(),
+ competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
+ roundId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { roundId, country, ...projectFields } = input
+
+ // Get the round to find competitionId, then competition to find programId
+ const round = await ctx.prisma.round.findUniqueOrThrow({
+ where: { id: roundId },
+ select: {
+ id: true,
+ name: true,
+ competition: {
+ select: {
+ id: true,
+ programId: true,
+ },
+ },
+ },
+ })
+
+ // Normalize country to ISO code if provided
+ const normalizedCountry = country
+ ? normalizeCountryToCode(country)
+ : undefined
+
+ const project = await ctx.prisma.$transaction(async (tx) => {
+ // 1. Create the project
+ const created = await tx.project.create({
+ data: {
+ programId: round.competition.programId,
+ title: projectFields.title,
+ teamName: projectFields.teamName,
+ description: projectFields.description,
+ country: normalizedCountry,
+ competitionCategory: projectFields.competitionCategory,
+ status: 'ASSIGNED',
+ },
+ })
+
+ // 2. Create ProjectRoundState entry
+ await tx.projectRoundState.create({
+ data: {
+ projectId: created.id,
+ roundId,
+ state: 'PENDING',
+ },
+ })
+
+ // 3. Create ProjectStatusHistory entry
+ await tx.projectStatusHistory.create({
+ data: {
+ projectId: created.id,
+ status: 'ASSIGNED',
+ changedBy: ctx.user.id,
+ },
+ })
+
+ return created
+ })
+
+ // Audit outside transaction
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE_AND_ASSIGN',
+ entityType: 'Project',
+ entityId: project.id,
+ detailsJson: {
+ title: input.title,
+ roundId,
+ roundName: round.name,
+ programId: round.competition.programId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return project
+ }),
})
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts
index 577efc8..e5e4433 100644
--- a/src/server/routers/round.ts
+++ b/src/server/routers/round.ts
@@ -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) || {}
+ 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) || {}
+ 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()
+ 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()
+ 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,
diff --git a/src/server/routers/roundEngine.ts b/src/server/routers/roundEngine.ts
index f595286..a56e48e 100644
--- a/src/server/routers/roundEngine.ts
+++ b/src/server/routers/roundEngine.ts
@@ -105,6 +105,7 @@ export const roundEngineRouter = router({
input.newState,
ctx.user.id,
ctx.prisma,
+ { adminOverride: true },
)
if (!result.success) {
throw new TRPCError({
@@ -133,6 +134,7 @@ export const roundEngineRouter = router({
input.newState,
ctx.user.id,
ctx.prisma,
+ { adminOverride: true },
)
}),
@@ -188,6 +190,14 @@ export const roundEngineRouter = router({
const roundIds = roundsToRemoveFrom.map((r) => r.id)
+ // Delete Assignment records first (Prisma cascade handles Evaluations)
+ await ctx.prisma.assignment.deleteMany({
+ where: {
+ projectId: input.projectId,
+ roundId: { in: roundIds },
+ },
+ })
+
// Delete ProjectRoundState entries for this project in all affected rounds
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
@@ -238,6 +248,14 @@ export const roundEngineRouter = router({
const roundIds = roundsToRemoveFrom.map((r) => r.id)
+ // Delete Assignment records first (Prisma cascade handles Evaluations)
+ await ctx.prisma.assignment.deleteMany({
+ where: {
+ projectId: { in: input.projectIds },
+ roundId: { in: roundIds },
+ },
+ })
+
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
projectId: { in: input.projectIds },
diff --git a/src/server/services/assignment-intent.ts b/src/server/services/assignment-intent.ts
index 4e6b7f6..6c2aebd 100644
--- a/src/server/services/assignment-intent.ts
+++ b/src/server/services/assignment-intent.ts
@@ -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)
}
// ============================================================================
diff --git a/src/server/services/evaluation-reminders.ts b/src/server/services/evaluation-reminders.ts
index 22dcbdb..260bba3 100644
--- a/src/server/services/evaluation-reminders.ts
+++ b/src/server/services/evaluation-reminders.ts
@@ -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
diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts
index 126a611..7ce271b 100644
--- a/src/server/services/in-app-notification.ts
+++ b/src/server/services/in-app-notification.ts
@@ -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
+): Promise {
+ 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)
}
}
diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts
index 35fd6e2..27c4c0a 100644
--- a/src/server/services/round-engine.ts
+++ b/src/server/services/round-engine.ts
@@ -54,6 +54,15 @@ const VALID_ROUND_TRANSITIONS: Record = {
ROUND_ARCHIVED: [],
}
+const VALID_PROJECT_TRANSITIONS: Record = {
+ 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 {
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 {
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 }