From baca483fcbcfe84f4efe14273e03c7e87aea0992 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Feb 2026 12:59:35 +0100 Subject: [PATCH] Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 1405 +++++++++++------ .../admin/round/project-states-table.tsx | 311 +++- src/lib/email.ts | 62 + src/server/routers/assignment.ts | 99 +- src/server/routers/evaluation.ts | 81 +- src/server/routers/project.ts | 93 ++ src/server/routers/round.ts | 198 ++- src/server/routers/roundEngine.ts | 18 + src/server/services/assignment-intent.ts | 11 +- src/server/services/evaluation-reminders.ts | 2 +- src/server/services/in-app-notification.ts | 38 +- src/server/services/round-engine.ts | 105 +- 12 files changed, 1814 insertions(+), 609 deletions(-) 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.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 + /> + ) : ( + + )} {typeCfg.label} - {/* Status dropdown */} - - - - - - {status === 'ROUND_DRAFT' && ( - activateMutation.mutate({ roundId })} - disabled={isTransitioning} + {/* Status dropdown with confirmation dialogs (4.1) */} + + + + + + + + + + {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 ? ( - - ) : juryGroup ? ( + {juryGroup ? ( <>

{juryMemberCount}

-

{juryGroup.name}

+
+

{juryGroup.name}

+ +
) : ( <>

-

No jury groups yet

+ )} @@ -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. +

+ + + + ) : ( + <> + {/* 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 && ( + + )} - )} - +
+ {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)'} +

+
+ +
+ )}
- - - {!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)'} -

-
- -
- )} - {/* Jury Progress + Score Distribution */} + {/* Jury Progress + Score Distribution (standalone 2-col grid) */}
- {/* Actions: Send Reminders + Notify + Export */} -
- - - -
+ {/* Card 2: Assignments — with action buttons in header */} + + +
+
+ Assignments + Individual jury-project assignments and actions +
+
+ + + +
+
+
+ + + +
- {/* 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() { 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() { 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 - -
- +
+
+
+

{assignments?.length ?? 0} individual assignments

- - + +
+
{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 */} -
- - - - - - - - - - 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 */} -
-
- - {selectedJurorId && ( -
+ {/* ── By Juror Tab ── */} + + {/* Juror Selector */} +
+ + + + + + + + + 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 */} +
+
+ + {selectedJurorId && ( +
- )} -
- )} -
- - {/* 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 ( -
)}
- -
-
- - - - + {/* 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 ( + + ) + }) + )} +
+
+
+ + + + + + + + {/* ── By Project Tab ── */} + + {/* Project Selector */} +
+ + + + + + + + + + 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 */} +
+
+ + {selectedProjectId && selectedJurorIds.size > 0 && ( + + )} +
+ + {/* 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 ( + + ) + }) + )} +
+
+
+ + + + + +
+
- + + {/* 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 ? (
- - - -
@@ -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 ── */} + +
+ + setTitle(e.target.value)} + /> +
+
+ + setTeamName(e.target.value)} + /> +
+
+
+ + setCountry(e.target.value)} + /> +
+
+ + +
+
+
+ + setDescription(e.target.value)} + /> +
+ + + + + +
+ + {/* ── 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 ( + + ) + })} +
+
+ + {poolResults && poolResults.total > 50 && ( +

+ Showing 50 of {poolResults.total} — refine your search for more specific results +

+ )} + + + + + +
+
+
+
+ ) +} 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 }