+ {/* ===== HEADER ===== */}
+
-
+
-
{round.name}
-
- {round.roundType.replace('_', ' ')}
+ {round.name}
+
+ {typeCfg.label}
+
+ {/* Status dropdown */}
+
+
+
+
+ {statusCfg.label}
+
+
+
+
+ {status === 'ROUND_DRAFT' && (
+ activateMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
+
+ Activate Round
+
+ )}
+ {status === 'ROUND_ACTIVE' && (
+ closeMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
+
+ Close Round
+
+ )}
+ {status === 'ROUND_CLOSED' && (
+ <>
+ activateMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
+
+ Reactivate Round
+
+
+ archiveMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
+
+ Archive Round
+
+ >
+ )}
+ {isTransitioning && (
+
+
+ Updating...
+
+ )}
+
+
-
{round.slug}
+
{typeCfg.description}
-
+ {/* Action buttons */}
+
{hasChanges && (
-
+
{updateMutation.isPending ? (
-
+
) : (
-
+
)}
- Save Changes
+ Save Config
)}
+ {(isEvaluation || isFiltering) && (
+
+
+
+ Assignments
+
+
+ )}
+
+
+
+ Project Pool
+
+
- {/* Tabs */}
-
+ {/* ===== STATS BAR ===== */}
+
+
+
+
+ {projectCount}
+
+ {Object.entries(stateCounts).map(([state, count]) => (
+
+ {String(count)} {state.toLowerCase().replace('_', ' ')}
+
+ ))}
+
+
+
+
+
+
+
+
+ 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 ? (
+ <>
+ {juryMemberCount}
+ {juryGroup.name}
+ >
+ ) : (
+ <>
+ —
+ No jury groups yet
+ >
+ )}
+
+
+
+
+
+
+
+ Window
+
+ {round.windowOpenAt || round.windowCloseAt ? (
+ <>
+
+ {round.windowOpenAt
+ ? new Date(round.windowOpenAt).toLocaleDateString()
+ : 'No start'}
+
+
+ {round.windowCloseAt
+ ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
+ : 'No deadline'}
+
+ >
+ ) : (
+ <>
+ —
+ No dates set
+ >
+ )}
+
+
+
+
+
+
+
+ Advancement
+
+ {round.advancementRules && round.advancementRules.length > 0 ? (
+ <>
+ {round.advancementRules.length}
+
+ {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
+
+ >
+ ) : (
+ <>
+ —
+ Admin selection
+ >
+ )}
+
+
+
+
+ {/* ===== TABS ===== */}
+
- Configuration
- Projects
- Submission Windows
+
+
+ Overview
+
+
+
+ Projects
+
+ {isFiltering && (
+
+
+ Filtering
+
+ )}
+ {isEvaluation && (
+
+
+ Assignments
+
+ )}
+
+
+ Config
+
+
+
+ Documents
+
- {/* Config Tab */}
+ {/* ===== OVERVIEW TAB ===== */}
+
+ {/* Quick Actions */}
+
+
+ Quick Actions
+ Common operations for this round
+
+
+
+ {/* Status transitions */}
+ {status === 'ROUND_DRAFT' && (
+
+
+
+
+
+
Activate Round
+
+ Start this round and allow project processing
+
+
+
+
+
+
+ Activate this round?
+
+ The round will go live. Projects can be processed and jury members will be able to see their assignments.
+
+
+
+ Cancel
+ activateMutation.mutate({ roundId })}>
+ Activate
+
+
+
+
+ )}
+
+ {status === 'ROUND_ACTIVE' && (
+
+
+
+
+
+
Close Round
+
+ Stop accepting changes and finalize results
+
+
+
+
+
+
+ Close this round?
+
+ No further changes will be accepted. You can reactivate later if needed.
+ {projectCount > 0 && (
+
+ {projectCount} projects are currently in this round.
+
+ )}
+
+
+
+ Cancel
+ closeMutation.mutate({ roundId })}>
+ Close Round
+
+
+
+
+ )}
+
+ {/* Assign projects */}
+
+
+
+
+
Assign Projects
+
+ Add projects from the pool to this round
+
+
+
+
+
+ {/* Filtering specific */}
+ {isFiltering && (
+
setActiveTab('filtering')}
+ className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
+ >
+
+
+
Run AI Filtering
+
+ Screen projects with AI and manual review
+
+
+
+ )}
+
+ {/* Jury assignment for evaluation/filtering */}
+ {(isEvaluation || isFiltering) && !juryGroup && (
+
{
+ const el = document.querySelector('[data-jury-select]')
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }}
+ className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50"
+ >
+
+
+
Assign Jury Group
+
+ No jury group assigned. Select one in the Jury card above.
+
+
+
+ )}
+
+ {/* Evaluation specific */}
+ {isEvaluation && (
+
+
+
+
+
Manage Assignments
+
+ Generate and review jury-project assignments
+
+
+
+
+ )}
+
+ {/* View projects */}
+
setActiveTab('projects')}
+ className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
+ >
+
+
+
Manage Projects
+
+ View, filter, and transition project states
+
+
+
+
+
+
+
+ {/* Round info */}
+
+
+
+ Round Details
+
+
+
+ Type
+ {typeCfg.label}
+
+
+ Status
+ {statusCfg.label}
+
+
+ Sort Order
+ {round.sortOrder}
+
+ {round.purposeKey && (
+
+ Purpose
+ {round.purposeKey}
+
+ )}
+
+ Jury Group
+
+ {juryGroup ? juryGroup.name : '—'}
+
+
+
+ Opens
+
+ {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'}
+
+
+
+ Closes
+
+ {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'}
+
+
+
+
+
+
+
+ Project Breakdown
+
+
+ {projectCount === 0 ? (
+
+ No projects assigned yet
+
+ ) : (
+
+ {['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => {
+ const count = stateCounts[state] || 0
+ if (count === 0) return null
+ const pct = ((count / projectCount) * 100).toFixed(0)
+ const colors: Record
= {
+ PENDING: 'bg-gray-400',
+ IN_PROGRESS: 'bg-blue-500',
+ PASSED: 'bg-green-500',
+ REJECTED: 'bg-red-500',
+ COMPLETED: 'bg-emerald-500',
+ WITHDRAWN: 'bg-orange-400',
+ }
+ return (
+
+
+ {state.toLowerCase().replace('_', ' ')}
+ {count} ({pct}%)
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ {/* ===== PROJECTS TAB ===== */}
+
+
+
+
+ {/* ===== FILTERING TAB ===== */}
+ {isFiltering && (
+
+
+
+ )}
+
+ {/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
+ {isEvaluation && (
+
+
+
+ )}
+
+ {/* ===== CONFIG TAB ===== */}
- {/* Projects Tab */}
-
-
-
-
- {/* Submission Windows Tab */}
+ {/* ===== DOCUMENTS TAB ===== */}
-
+
)
}
+
+// ===== Inline sub-component for evaluation round assignments =====
+
+function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) {
+ const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({
+ roundId,
+ requiredReviews: 3,
+ })
+
+ const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
+ { roundId, requiredReviews: 3 },
+ )
+
+ return (
+
+ {/* Coverage stats */}
+ {coverageLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : coverage ? (
+
+
+
+ Fully Assigned
+
+
+
+ {coverage.fullyAssigned || 0}
+
+ of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%)
+
+
+
+
+
+ Avg Reviews/Project
+
+
+
+ {coverage.avgReviewsPerProject?.toFixed(1) || '0'}
+ Target: 3 per project
+
+
+
+
+ Unassigned
+
+
+
+ {coverage.unassigned || 0}
+ Need more assignments
+
+
+
+ ) : null}
+
+ {/* Unassigned queue */}
+
+
+
+
+ Unassigned Projects
+ Projects with fewer than 3 jury assignments
+
+
+
+
+ Full Assignment Dashboard
+
+
+
+
+
+
+ {unassignedLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : unassigned && unassigned.length > 0 ? (
+
+ {unassigned.map((project: any) => (
+
+
+
{project.title}
+
+ {project.competitionCategory || 'No category'}
+ {project.teamName && ` · ${project.teamName}`}
+
+
+
+ {project.assignmentCount || 0} / 3
+
+
+ ))}
+
+ ) : (
+
+ All projects have sufficient assignments
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/admin/round/file-requirements-editor.tsx b/src/components/admin/round/file-requirements-editor.tsx
new file mode 100644
index 0000000..2e08d1a
--- /dev/null
+++ b/src/components/admin/round/file-requirements-editor.tsx
@@ -0,0 +1,399 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import {
+ Loader2,
+ Plus,
+ Pencil,
+ Trash2,
+ FileText,
+ GripVertical,
+ FileCheck,
+ FileQuestion,
+} from 'lucide-react'
+
+type FileRequirementsEditorProps = {
+ roundId: string
+ windowOpenAt?: Date | string | null
+ windowCloseAt?: Date | string | null
+}
+
+type FormState = {
+ name: string
+ description: string
+ acceptedMimeTypes: string
+ maxSizeMB: string
+ isRequired: boolean
+}
+
+const emptyForm: FormState = {
+ name: '',
+ description: '',
+ acceptedMimeTypes: '',
+ maxSizeMB: '',
+ isRequired: true,
+}
+
+const COMMON_MIME_PRESETS: { label: string; value: string }[] = [
+ { label: 'PDF only', value: 'application/pdf' },
+ { label: 'Images', value: 'image/png, image/jpeg, image/webp' },
+ { label: 'Video', value: 'video/mp4, video/quicktime, video/webm' },
+ { label: 'Documents', value: 'application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
+ { label: 'Spreadsheets', value: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv' },
+ { label: 'Presentations', value: 'application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation' },
+ { label: 'Any file', value: '' },
+]
+
+export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingId, setEditingId] = useState
(null)
+ const [form, setForm] = useState(emptyForm)
+
+ const utils = trpc.useUtils()
+
+ const { data: requirements, isLoading } = trpc.file.listRequirements.useQuery({ roundId })
+
+ const createMutation = trpc.file.createRequirement.useMutation({
+ onSuccess: () => {
+ utils.file.listRequirements.invalidate({ roundId })
+ toast.success('Requirement added')
+ closeDialog()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const updateMutation = trpc.file.updateRequirement.useMutation({
+ onSuccess: () => {
+ utils.file.listRequirements.invalidate({ roundId })
+ toast.success('Requirement updated')
+ closeDialog()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const deleteMutation = trpc.file.deleteRequirement.useMutation({
+ onSuccess: () => {
+ utils.file.listRequirements.invalidate({ roundId })
+ toast.success('Requirement removed')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const closeDialog = () => {
+ setDialogOpen(false)
+ setEditingId(null)
+ setForm(emptyForm)
+ }
+
+ const openCreateDialog = () => {
+ setForm(emptyForm)
+ setEditingId(null)
+ setDialogOpen(true)
+ }
+
+ const openEditDialog = (req: any) => {
+ setForm({
+ name: req.name,
+ description: req.description || '',
+ acceptedMimeTypes: (req.acceptedMimeTypes || []).join(', '),
+ maxSizeMB: req.maxSizeMB?.toString() || '',
+ isRequired: req.isRequired ?? true,
+ })
+ setEditingId(req.id)
+ setDialogOpen(true)
+ }
+
+ const handleSubmit = () => {
+ const mimeTypes = form.acceptedMimeTypes
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+
+ const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
+
+ if (editingId) {
+ updateMutation.mutate({
+ id: editingId,
+ name: form.name,
+ description: form.description || null,
+ acceptedMimeTypes: mimeTypes,
+ maxSizeMB: maxSize ?? null,
+ isRequired: form.isRequired,
+ })
+ } else {
+ createMutation.mutate({
+ roundId,
+ name: form.name,
+ description: form.description || undefined,
+ acceptedMimeTypes: mimeTypes,
+ maxSizeMB: maxSize,
+ isRequired: form.isRequired,
+ sortOrder: (requirements?.length ?? 0),
+ })
+ }
+ }
+
+ const isSaving = createMutation.isPending || updateMutation.isPending
+
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3].map((i) => )}
+
+ )
+ }
+
+ return (
+
+ {/* Submission period info */}
+
+
+
+
+
Submission Period
+
+ Applicants can upload documents during the round's active window
+
+
+
+ {windowOpenAt || windowCloseAt ? (
+ <>
+
+ {windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} —{' '}
+ {windowCloseAt ? new Date(windowCloseAt).toLocaleDateString() : 'No deadline'}
+
+
+ Set in the Config tab under round time windows
+
+ >
+ ) : (
+
No dates configured — set in Config tab
+ )}
+
+
+
+
+
+ {/* Requirements list */}
+
+
+
+
+ Required Documents
+
+ Define what files applicants must submit for this round
+
+
+
+
+ Add Requirement
+
+
+
+
+ {!requirements || requirements.length === 0 ? (
+
+
+
+
+
No Document Requirements
+
+ Add requirements to specify what documents applicants must upload during this round.
+
+
+
+ Add First Requirement
+
+
+ ) : (
+
+ {requirements.map((req: any) => (
+
+
+ {req.isRequired ? (
+
+ ) : (
+
+ )}
+
+
+
+
{req.name}
+
+ {req.isRequired ? 'Required' : 'Optional'}
+
+
+ {req.description && (
+
{req.description}
+ )}
+
+ {req.acceptedMimeTypes?.length > 0 ? (
+
+ {req.acceptedMimeTypes.map((t: string) => {
+ if (t === 'application/pdf') return 'PDF'
+ if (t.startsWith('image/')) return t.replace('image/', '').toUpperCase()
+ if (t.startsWith('video/')) return t.replace('video/', '').toUpperCase()
+ return t.split('/').pop()?.toUpperCase() || t
+ }).join(', ')}
+
+ ) : (
+
+ Any file type
+
+ )}
+ {req.maxSizeMB && (
+
+ Max {req.maxSizeMB} MB
+
+ )}
+
+
+
+
openEditDialog(req)}>
+
+
+
+
+
+
+
+
+
+
+ Delete requirement?
+
+ This will remove "{req.name}" from the round. Previously uploaded files will not be deleted.
+
+
+
+ Cancel
+ deleteMutation.mutate({ id: req.id })}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Create / Edit Dialog */}
+
{ if (!open) closeDialog() }}>
+
+
+ {editingId ? 'Edit Requirement' : 'Add Document Requirement'}
+
+ {editingId
+ ? 'Update the document requirement details.'
+ : 'Define a new document that applicants must submit.'}
+
+
+
+
+ Name
+ setForm((f) => ({ ...f, name: e.target.value }))}
+ />
+
+
+ Description
+
+
+
Accepted File Types
+
setForm((f) => ({ ...f, acceptedMimeTypes: e.target.value }))}
+ />
+
+ {COMMON_MIME_PRESETS.map((preset) => (
+ setForm((f) => ({ ...f, acceptedMimeTypes: preset.value }))}
+ className="text-[10px] px-2 py-1 rounded-full border hover:bg-muted transition-colors"
+ >
+ {preset.label}
+
+ ))}
+
+
+
+ Max File Size (MB)
+ setForm((f) => ({ ...f, maxSizeMB: e.target.value }))}
+ />
+
+
+ setForm((f) => ({ ...f, isRequired: !!checked }))}
+ />
+
+ Required document (applicant must upload to proceed)
+
+
+
+
+ Cancel
+
+ {isSaving && }
+ {editingId ? 'Update' : 'Add Requirement'}
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx
new file mode 100644
index 0000000..4a4d857
--- /dev/null
+++ b/src/components/admin/round/filtering-dashboard.tsx
@@ -0,0 +1,841 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Progress } from '@/components/ui/progress'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import {
+ Play,
+ Loader2,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ RefreshCw,
+ Eye,
+ ChevronLeft,
+ ChevronRight,
+ Shield,
+ Sparkles,
+ Ban,
+ Flag,
+ RotateCcw,
+} from 'lucide-react'
+
+type FilteringDashboardProps = {
+ competitionId: string
+ roundId: string
+}
+
+type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
+
+type AIScreeningData = {
+ meetsCriteria?: boolean
+ confidence?: number
+ reasoning?: string
+ qualityScore?: number
+ spamRisk?: boolean
+}
+
+export function FilteringDashboard({ competitionId, roundId }: FilteringDashboardProps) {
+ const [outcomeFilter, setOutcomeFilter] = useState('ALL')
+ const [page, setPage] = useState(1)
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [pollingJobId, setPollingJobId] = useState(null)
+ const [overrideDialogOpen, setOverrideDialogOpen] = useState(false)
+ const [overrideTarget, setOverrideTarget] = useState<{ id: string; name: string } | null>(null)
+ const [overrideOutcome, setOverrideOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
+ const [overrideReason, setOverrideReason] = useState('')
+ const [bulkOverrideDialogOpen, setBulkOverrideDialogOpen] = useState(false)
+ const [bulkOutcome, setBulkOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
+ const [bulkReason, setBulkReason] = useState('')
+ const [detailResult, setDetailResult] = useState(null)
+
+ const utils = trpc.useUtils()
+
+ // -- Queries --
+ const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
+ { roundId },
+ )
+
+ const { data: latestJob, isLoading: jobLoading } = trpc.filtering.getLatestJob.useQuery(
+ { roundId },
+ )
+
+ const { data: rules } = trpc.filtering.getRules.useQuery({ roundId })
+
+ const { data: resultsPage, isLoading: resultsLoading } = trpc.filtering.getResults.useQuery(
+ {
+ roundId,
+ outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter,
+ page,
+ perPage: 25,
+ },
+ )
+
+ const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
+ { jobId: pollingJobId! },
+ {
+ enabled: !!pollingJobId,
+ refetchInterval: 2000,
+ },
+ )
+
+ // Stop polling when job completes
+ useEffect(() => {
+ if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) {
+ setPollingJobId(null)
+ utils.filtering.getLatestJob.invalidate({ roundId })
+ utils.filtering.getResults.invalidate()
+ utils.filtering.getResultStats.invalidate({ roundId })
+ if (jobStatus.status === 'COMPLETED') {
+ toast.success(`Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered, ${jobStatus.flaggedCount} flagged`)
+ } else {
+ toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
+ }
+ }
+ }, [jobStatus, roundId, utils])
+
+ // Auto-detect running job on mount
+ useEffect(() => {
+ if (latestJob && latestJob.status === 'RUNNING') {
+ setPollingJobId(latestJob.id)
+ }
+ }, [latestJob])
+
+ // -- Mutations --
+ const startJobMutation = trpc.filtering.startJob.useMutation({
+ onSuccess: (data) => {
+ setPollingJobId(data.jobId)
+ toast.success('Filtering job started')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const overrideMutation = trpc.filtering.overrideResult.useMutation({
+ onSuccess: () => {
+ utils.filtering.getResults.invalidate()
+ utils.filtering.getResultStats.invalidate({ roundId })
+ setOverrideDialogOpen(false)
+ setOverrideTarget(null)
+ setOverrideReason('')
+ toast.success('Override applied')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const bulkOverrideMutation = trpc.filtering.bulkOverride.useMutation({
+ onSuccess: (data) => {
+ utils.filtering.getResults.invalidate()
+ utils.filtering.getResultStats.invalidate({ roundId })
+ setBulkOverrideDialogOpen(false)
+ setSelectedIds(new Set())
+ setBulkReason('')
+ toast.success(`${data.updated} results overridden`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const finalizeMutation = trpc.filtering.finalizeResults.useMutation({
+ onSuccess: (data) => {
+ utils.filtering.getResults.invalidate()
+ utils.filtering.getResultStats.invalidate({ roundId })
+ toast.success(
+ `Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
+ (data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
+ )
+ if (data.categoryWarnings.length > 0) {
+ data.categoryWarnings.forEach((w) => toast.warning(w))
+ }
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // -- Handlers --
+ const handleStartJob = () => {
+ startJobMutation.mutate({ roundId })
+ }
+
+ const handleOverride = () => {
+ if (!overrideTarget) return
+ overrideMutation.mutate({
+ id: overrideTarget.id,
+ finalOutcome: overrideOutcome,
+ reason: overrideReason,
+ })
+ }
+
+ const handleBulkOverride = () => {
+ bulkOverrideMutation.mutate({
+ ids: Array.from(selectedIds),
+ finalOutcome: bulkOutcome,
+ reason: bulkReason,
+ })
+ }
+
+ const handleFinalize = () => {
+ finalizeMutation.mutate({ roundId })
+ }
+
+ const toggleSelect = (id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const toggleSelectAll = useCallback(() => {
+ if (!resultsPage) return
+ const allIds = resultsPage.results.map((r: any) => r.id)
+ const allSelected = allIds.every((id: string) => selectedIds.has(id))
+ if (allSelected) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ allIds.forEach((id: string) => next.delete(id))
+ return next
+ })
+ } else {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ allIds.forEach((id: string) => next.add(id))
+ return next
+ })
+ }
+ }, [resultsPage, selectedIds])
+
+ const parseAIData = (json: unknown): AIScreeningData | null => {
+ if (!json || typeof json !== 'object') return null
+ return json as AIScreeningData
+ }
+
+ // Is there a running job?
+ const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
+ const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null)
+ const hasResults = stats && stats.total > 0
+ const hasRules = rules && rules.length > 0
+
+ return (
+
+ {/* Job Control */}
+
+
+
+
+ AI Filtering
+
+ Run AI screening against {hasRules ? rules.length : 0} active rule{rules?.length !== 1 ? 's' : ''}
+
+
+
+
+ {isRunning ? (
+
+ ) : (
+
+ )}
+ {isRunning ? 'Running...' : 'Run Filtering'}
+
+ {hasResults && (
+
+
+
+
+ Finalize Results
+
+
+
+
+ Finalize Filtering Results?
+
+ This will mark PASSED projects as eligible and FILTERED_OUT projects as rejected.
+ {stats && (
+
+ {stats.passed} will pass, {stats.filteredOut} will be filtered out, {stats.flagged} flagged for review.
+
+ )}
+ This action can be reversed but requires manual intervention.
+
+
+
+ Cancel
+
+ {finalizeMutation.isPending && }
+ Finalize
+
+
+
+
+ )}
+
+
+
+
+ {/* Job Progress */}
+ {isRunning && activeJob && (
+
+
+
+
+ Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'}
+
+
+ {activeJob.processedCount}/{activeJob.totalProjects}
+
+
+
0
+ ? (activeJob.processedCount / activeJob.totalProjects) * 100
+ : 0
+ }
+ />
+
+
+ )}
+
+ {/* Last job summary */}
+ {!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
+
+
+
+ Last run completed: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
+
+ ({new Date(latestJob.completedAt!).toLocaleDateString()})
+
+
+
+ )}
+
+ {!isRunning && latestJob && latestJob.status === 'FAILED' && (
+
+
+
+ Last run failed: {latestJob.errorMessage || 'Unknown error'}
+
+
+ )}
+
+ {!hasRules && (
+
+
+ No active filtering rules configured. Add rules in the Configuration tab first.
+
+
+ )}
+
+
+ {/* Stats Cards */}
+ {statsLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : stats && stats.total > 0 ? (
+
+
+
+ Total
+
+
+
+ {stats.total}
+ Projects screened
+
+
+
+
+ Passed
+
+
+
+ {stats.passed}
+
+ {stats.total > 0 ? `${((stats.passed / stats.total) * 100).toFixed(0)}%` : '0%'}
+
+
+
+
+
+ Filtered Out
+
+
+
+ {stats.filteredOut}
+
+ {stats.total > 0 ? `${((stats.filteredOut / stats.total) * 100).toFixed(0)}%` : '0%'}
+
+
+
+
+
+ Flagged
+
+
+
+ {stats.flagged}
+ Need review
+
+
+
+
+ Overridden
+
+
+
+ {stats.overridden}
+ Manual changes
+
+
+
+ ) : null}
+
+ {/* Results Table */}
+ {(hasResults || resultsLoading) && (
+
+
+
+
+ Filtering Results
+
+ Review AI screening outcomes and override decisions
+
+
+
+ {
+ setOutcomeFilter(v as OutcomeFilter)
+ setPage(1)
+ setSelectedIds(new Set())
+ }}
+ >
+
+
+
+
+ All Outcomes
+ Passed
+ Filtered Out
+ Flagged
+
+
+ {selectedIds.size > 0 && (
+ setBulkOverrideDialogOpen(true)}
+ >
+ Override {selectedIds.size} Selected
+
+ )}
+ {
+ utils.filtering.getResults.invalidate()
+ utils.filtering.getResultStats.invalidate({ roundId })
+ }}
+ >
+
+
+
+
+
+
+ {resultsLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : resultsPage && resultsPage.results.length > 0 ? (
+
+ {/* Table Header */}
+
+
+ 0 &&
+ resultsPage.results.every((r: any) => selectedIds.has(r.id))
+ }
+ onCheckedChange={toggleSelectAll}
+ />
+
+
Project
+
Category
+
Outcome
+
Confidence
+
Quality
+
Actions
+
+
+ {/* Rows */}
+ {resultsPage.results.map((result: any) => {
+ const ai = parseAIData(result.aiScreeningJson)
+ const effectiveOutcome = result.finalOutcome || result.outcome
+
+ return (
+
+
+ toggleSelect(result.id)}
+ />
+
+
+
{result.project?.title || 'Unknown'}
+
+ {result.project?.teamName}
+ {result.project?.country && ` · ${result.project.country}`}
+
+
+
+
+ {result.project?.competitionCategory || '—'}
+
+
+
+
+
+
+ {ai?.confidence != null ? (
+
+ ) : (
+ —
+ )}
+
+
+ {ai?.qualityScore != null ? (
+ = 7 ? 'text-green-700' :
+ ai.qualityScore >= 4 ? 'text-amber-700' :
+ 'text-red-700'
+ }`}>
+ {ai.qualityScore}/10
+
+ ) : (
+ —
+ )}
+
+
+ setDetailResult(result)}
+ title="View AI feedback"
+ >
+
+
+ {
+ setOverrideTarget({ id: result.id, name: result.project?.title || 'Unknown' })
+ setOverrideOutcome(effectiveOutcome === 'PASSED' ? 'FILTERED_OUT' : 'PASSED')
+ setOverrideDialogOpen(true)
+ }}
+ title="Override decision"
+ >
+
+
+
+
+ )
+ })}
+
+ {/* Pagination */}
+ {resultsPage.totalPages > 1 && (
+
+
+ Page {resultsPage.page} of {resultsPage.totalPages} ({resultsPage.total} total)
+
+
+ setPage((p) => p - 1)}
+ >
+
+
+ = resultsPage.totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+
+
+
+
+ )}
+
+ ) : (
+
+
+
No results yet
+
+ Run the filtering job to screen projects
+
+
+ )}
+
+
+ )}
+
+ {/* AI Detail Dialog */}
+
!open && setDetailResult(null)}>
+
+
+ {detailResult?.project?.title || 'Project'}
+
+ AI screening feedback and reasoning
+
+
+ {detailResult && (() => {
+ const ai = parseAIData(detailResult.aiScreeningJson)
+ const effectiveOutcome = detailResult.finalOutcome || detailResult.outcome
+ return (
+
+
+
+ {detailResult.finalOutcome && detailResult.finalOutcome !== detailResult.outcome && (
+
+ Original:
+
+ )}
+
+
+ {ai && (
+ <>
+
+
+
Confidence
+
+ {ai.confidence != null ? `${(ai.confidence * 100).toFixed(0)}%` : '—'}
+
+
+
+
Quality
+
+ {ai.qualityScore != null ? `${ai.qualityScore}/10` : '—'}
+
+
+
+
Spam Risk
+
+ {ai.spamRisk ? 'Yes' : 'No'}
+
+
+
+
+ {ai.reasoning && (
+
+
AI Reasoning
+
+ {ai.reasoning}
+
+
+ )}
+ >
+ )}
+
+ {detailResult.overrideReason && (
+
+
Override Reason
+
+ {detailResult.overrideReason}
+ {detailResult.overriddenByUser && (
+
+ By {detailResult.overriddenByUser.name || detailResult.overriddenByUser.email}
+ {detailResult.overriddenAt && ` on ${new Date(detailResult.overriddenAt).toLocaleDateString()}`}
+
+ )}
+
+
+ )}
+
+ {/* Rule-by-rule results */}
+ {detailResult.ruleResultsJson && Array.isArray(detailResult.ruleResultsJson) && (
+
+
Rule Results
+
+ {(detailResult.ruleResultsJson as any[]).map((rule: any, i: number) => (
+
+ {rule.ruleName || `Rule ${i + 1}`}
+
+ {rule.passed ? 'Pass' : 'Fail'}
+
+
+ ))}
+
+
+ )}
+
+ )
+ })()}
+
+
+
+ {/* Single Override Dialog */}
+
+
+
+ Override Decision
+
+ Change the outcome for: {overrideTarget?.name}
+
+
+
+
+ New Outcome
+ setOverrideOutcome(v as any)}>
+
+
+
+
+ Passed
+ Filtered Out
+ Flagged
+
+
+
+
+ Reason
+
+
+
+ setOverrideDialogOpen(false)}>Cancel
+
+ {overrideMutation.isPending && }
+ Apply Override
+
+
+
+
+
+ {/* Bulk Override Dialog */}
+
+
+
+ Bulk Override
+
+ Override {selectedIds.size} selected results
+
+
+
+
+ Set All To
+ setBulkOutcome(v as any)}>
+
+
+
+
+ Passed
+ Filtered Out
+ Flagged
+
+
+
+
+ Reason
+
+
+
+ setBulkOverrideDialogOpen(false)}>Cancel
+
+ {bulkOverrideMutation.isPending && }
+ Override {selectedIds.size} Results
+
+
+
+
+
+ )
+}
+
+// -- Sub-components --
+
+function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) {
+ const styles: Record = {
+ PASSED: 'bg-green-100 text-green-800 border-green-200',
+ FILTERED_OUT: 'bg-red-100 text-red-800 border-red-200',
+ FLAGGED: 'bg-amber-100 text-amber-800 border-amber-200',
+ }
+ return (
+
+ {outcome === 'FILTERED_OUT' ? 'Filtered' : outcome === 'PASSED' ? 'Passed' : 'Flagged'}
+ {overridden && ' *'}
+
+ )
+}
+
+function ConfidenceIndicator({ value }: { value: number }) {
+ const pct = Math.round(value * 100)
+ const color = pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
+ return (
+
+ {pct}%
+
+ )
+}
diff --git a/src/components/admin/round/project-states-table.tsx b/src/components/admin/round/project-states-table.tsx
index 0200994..d622b7a 100644
--- a/src/components/admin/round/project-states-table.tsx
+++ b/src/components/admin/round/project-states-table.tsx
@@ -1,7 +1,72 @@
'use client'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Layers } from 'lucide-react'
+import { useState, useCallback } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ Loader2,
+ MoreHorizontal,
+ ArrowRight,
+ XCircle,
+ CheckCircle2,
+ Clock,
+ Play,
+ LogOut,
+ Layers,
+ Trash2,
+ Plus,
+} from 'lucide-react'
+import Link from 'next/link'
+import type { Route } from 'next'
+
+const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
+type ProjectState = (typeof PROJECT_STATES)[number]
+
+const stateConfig: Record = {
+ PENDING: { label: 'Pending', color: 'bg-gray-100 text-gray-700 border-gray-200', icon: Clock },
+ IN_PROGRESS: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Play },
+ PASSED: { label: 'Passed', color: 'bg-green-100 text-green-700 border-green-200', icon: CheckCircle2 },
+ REJECTED: { label: 'Rejected', color: 'bg-red-100 text-red-700 border-red-200', icon: XCircle },
+ COMPLETED: { label: 'Completed', color: 'bg-emerald-100 text-emerald-700 border-emerald-200', icon: CheckCircle2 },
+ WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
+}
type ProjectStatesTableProps = {
competitionId: string
@@ -9,25 +74,395 @@ type ProjectStatesTableProps = {
}
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
- return (
-
-
- Project States
-
- Projects participating in this round
-
-
-
-
-
-
+ const [selectedIds, setSelectedIds] = useState
>(new Set())
+ const [stateFilter, setStateFilter] = useState('ALL')
+ const [batchDialogOpen, setBatchDialogOpen] = useState(false)
+ const [batchNewState, setBatchNewState] = useState('PASSED')
+ const [removeConfirmId, setRemoveConfirmId] = useState(null)
+ const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
+
+ const utils = trpc.useUtils()
+
+ const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
+ { roundId },
+ )
+
+ const transitionMutation = trpc.roundEngine.transitionProject.useMutation({
+ onSuccess: () => {
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ toast.success('Project state updated')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const batchTransitionMutation = trpc.roundEngine.batchTransition.useMutation({
+ onSuccess: (data) => {
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ setSelectedIds(new Set())
+ setBatchDialogOpen(false)
+ toast.success(`${data.succeeded.length} projects updated${data.failed.length > 0 ? `, ${data.failed.length} failed` : ''}`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const removeMutation = trpc.roundEngine.removeFromRound.useMutation({
+ onSuccess: (data) => {
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ setRemoveConfirmId(null)
+ toast.success(`Removed from ${data.removedFromRounds} round(s)`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const batchRemoveMutation = trpc.roundEngine.batchRemoveFromRound.useMutation({
+ onSuccess: (data) => {
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ setSelectedIds(new Set())
+ setBatchRemoveOpen(false)
+ toast.success(`${data.removedCount} project(s) removed from this round and later rounds`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const handleTransition = (projectId: string, newState: ProjectState) => {
+ transitionMutation.mutate({ projectId, roundId, newState })
+ }
+
+ const handleBatchTransition = () => {
+ batchTransitionMutation.mutate({
+ projectIds: Array.from(selectedIds),
+ roundId,
+ newState: batchNewState,
+ })
+ }
+
+ const toggleSelect = (id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const filtered = projectStates?.filter((ps: any) =>
+ stateFilter === 'ALL' ? true : ps.state === stateFilter
+ ) ?? []
+
+ const toggleSelectAll = useCallback(() => {
+ const ids = filtered.map((ps: any) => ps.projectId)
+ const allSelected = ids.length > 0 && ids.every((id: string) => selectedIds.has(id))
+ if (allSelected) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ ids.forEach((id: string) => next.delete(id))
+ return next
+ })
+ } else {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ ids.forEach((id: string) => next.add(id))
+ return next
+ })
+ }
+ }, [filtered, selectedIds])
+
+ // State counts
+ const counts = projectStates?.reduce((acc: Record, ps: any) => {
+ acc[ps.state] = (acc[ps.state] || 0) + 1
+ return acc
+ }, {} as Record) ?? {}
+
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ )
+ }
+
+ if (!projectStates || projectStates.length === 0) {
+ return (
+
+
+
+
+
+
+
No Projects in This Round
+
+ Assign projects from the Project Pool to this round to get started.
+
+
+
+
+ Go to Project Pool
+
+
- No Active Projects
-
- Project states will appear here when the round is active
-
+
+
+ )
+ }
+
+ return (
+
+ {/* Top bar: filters + add button */}
+
+
+ { setStateFilter('ALL'); setSelectedIds(new Set()) }}
+ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
+ stateFilter === 'ALL'
+ ? 'bg-foreground text-background border-foreground'
+ : 'bg-muted text-muted-foreground border-transparent hover:border-border'
+ }`}
+ >
+ All ({projectStates.length})
+
+ {PROJECT_STATES.map((state) => {
+ const count = counts[state] || 0
+ if (count === 0) return null
+ const cfg = stateConfig[state]
+ return (
+ { setStateFilter(state); setSelectedIds(new Set()) }}
+ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
+ stateFilter === state
+ ? cfg.color + ' border-current'
+ : 'bg-muted text-muted-foreground border-transparent hover:border-border'
+ }`}
+ >
+ {cfg.label} ({count})
+
+ )
+ })}
-
-
+
+
+
+ Add from Pool
+
+
+
+
+ {/* Bulk actions bar */}
+ {selectedIds.size > 0 && (
+
+
{selectedIds.size} selected
+
+
setBatchDialogOpen(true)}
+ >
+
+ Change State
+
+
setBatchRemoveOpen(true)}
+ >
+
+ Remove from Round
+
+
setSelectedIds(new Set())}
+ >
+ Clear
+
+
+
+ )}
+
+ {/* Table */}
+
+ {/* Header */}
+
+
+ 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
+ onCheckedChange={toggleSelectAll}
+ />
+
+
Project
+
Category
+
State
+
Entered
+
+
+
+ {/* Rows */}
+ {filtered.map((ps: any) => {
+ const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
+ const StateIcon = cfg.icon
+ return (
+
+
+ toggleSelect(ps.projectId)}
+ />
+
+
+
{ps.project?.title || 'Unknown'}
+
{ps.project?.teamName}
+
+
+
+ {ps.project?.competitionCategory || '—'}
+
+
+
+
+
+ {cfg.label}
+
+
+
+ {ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
+
+
+
+
+
+
+
+
+
+ {PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
+ const sCfg = stateConfig[state]
+ return (
+ handleTransition(ps.projectId, state)}
+ disabled={transitionMutation.isPending}
+ >
+
+ Move to {sCfg.label}
+
+ )
+ })}
+
+ setRemoveConfirmId(ps.projectId)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Remove from Round
+
+
+
+
+
+ )
+ })}
+
+
+ {/* Single Remove Confirmation */}
+
{ if (!open) setRemoveConfirmId(null) }}>
+
+
+ Remove project from this round?
+
+ The project will be removed from this round and all subsequent rounds.
+ It will remain in any prior rounds it was already assigned to.
+
+
+
+ Cancel
+ {
+ if (removeConfirmId) {
+ removeMutation.mutate({ projectId: removeConfirmId, roundId })
+ }
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={removeMutation.isPending}
+ >
+ {removeMutation.isPending && }
+ Remove
+
+
+
+
+
+ {/* Batch Remove Confirmation */}
+
+
+
+ Remove {selectedIds.size} projects from this round?
+
+ These projects will be removed from this round and all subsequent rounds in the competition.
+ They will remain in any prior rounds they were already assigned to.
+
+
+
+ Cancel
+ {
+ batchRemoveMutation.mutate({
+ projectIds: Array.from(selectedIds),
+ roundId,
+ })
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={batchRemoveMutation.isPending}
+ >
+ {batchRemoveMutation.isPending && }
+ Remove {selectedIds.size} Projects
+
+
+
+
+
+ {/* Batch Transition Dialog */}
+
+
+
+ Change State for {selectedIds.size} Projects
+
+ All selected projects will be moved to the new state.
+
+
+
+ New State
+ setBatchNewState(v as ProjectState)}>
+
+
+
+
+ {PROJECT_STATES.map((state) => (
+
+ {stateConfig[state].label}
+
+ ))}
+
+
+
+
+ setBatchDialogOpen(false)}>Cancel
+
+ {batchTransitionMutation.isPending && }
+ Update {selectedIds.size} Projects
+
+
+
+
+
)
}
diff --git a/src/server/routers/cohort.ts b/src/server/routers/cohort.ts
index 48f741f..eee14c8 100644
--- a/src/server/routers/cohort.ts
+++ b/src/server/routers/cohort.ts
@@ -33,33 +33,30 @@ export const cohortRouter = router({
}
}
- const cohort = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.cohort.create({
- data: {
- roundId: input.roundId,
- name: input.name,
- votingMode: input.votingMode,
- windowOpenAt: input.windowOpenAt ?? null,
- windowCloseAt: input.windowCloseAt ?? null,
- },
- })
+ const cohort = await ctx.prisma.cohort.create({
+ data: {
+ roundId: input.roundId,
+ name: input.name,
+ votingMode: input.votingMode,
+ windowOpenAt: input.windowOpenAt ?? null,
+ windowCloseAt: input.windowCloseAt ?? null,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'Cohort',
- entityId: created.id,
- detailsJson: {
- roundId: input.roundId,
- name: input.name,
- votingMode: input.votingMode,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ // Audit outside transaction so failures don't roll back the create
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'Cohort',
+ entityId: cohort.id,
+ detailsJson: {
+ roundId: input.roundId,
+ name: input.name,
+ votingMode: input.votingMode,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return cohort
@@ -157,32 +154,29 @@ export const cohortRouter = router({
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
: cohort.windowCloseAt
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.cohort.update({
- where: { id: input.cohortId },
- data: {
- isOpen: true,
- windowOpenAt: now,
- windowCloseAt: closeAt,
- },
- })
+ const updated = await ctx.prisma.cohort.update({
+ where: { id: input.cohortId },
+ data: {
+ isOpen: true,
+ windowOpenAt: now,
+ windowCloseAt: closeAt,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'COHORT_VOTING_OPENED',
- entityType: 'Cohort',
- entityId: input.cohortId,
- detailsJson: {
- openedAt: now.toISOString(),
- closesAt: closeAt?.toISOString() ?? null,
- projectCount: cohort._count.projects,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the voting open
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'COHORT_VOTING_OPENED',
+ entityType: 'Cohort',
+ entityId: input.cohortId,
+ detailsJson: {
+ openedAt: now.toISOString(),
+ closesAt: closeAt?.toISOString() ?? null,
+ projectCount: cohort._count.projects,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
@@ -207,30 +201,27 @@ export const cohortRouter = router({
const now = new Date()
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.cohort.update({
- where: { id: input.cohortId },
- data: {
- isOpen: false,
- windowCloseAt: now,
- },
- })
+ const updated = await ctx.prisma.cohort.update({
+ where: { id: input.cohortId },
+ data: {
+ isOpen: false,
+ windowCloseAt: now,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'COHORT_VOTING_CLOSED',
- entityType: 'Cohort',
- entityId: input.cohortId,
- detailsJson: {
- closedAt: now.toISOString(),
- wasOpenSince: cohort.windowOpenAt?.toISOString(),
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the voting close
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'COHORT_VOTING_CLOSED',
+ entityType: 'Cohort',
+ entityId: input.cohortId,
+ detailsJson: {
+ closedAt: now.toISOString(),
+ wasOpenSince: cohort.windowOpenAt?.toISOString(),
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
diff --git a/src/server/routers/competition.ts b/src/server/routers/competition.ts
index 3c0d60d..c45155e 100644
--- a/src/server/routers/competition.ts
+++ b/src/server/routers/competition.ts
@@ -36,23 +36,19 @@ export const competitionRouter = router({
where: { id: input.programId },
})
- const competition = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.competition.create({
- data: input,
- })
+ const competition = await ctx.prisma.competition.create({
+ data: input,
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'Competition',
- entityId: created.id,
- detailsJson: { name: input.name, programId: input.programId },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'Competition',
+ entityId: competition.id,
+ detailsJson: { name: input.name, programId: input.programId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return competition
@@ -178,33 +174,29 @@ export const competitionRouter = router({
}
}
- const competition = await ctx.prisma.$transaction(async (tx) => {
- const previous = await tx.competition.findUniqueOrThrow({ where: { id } })
+ const previous = await ctx.prisma.competition.findUniqueOrThrow({ where: { id } })
- const updated = await tx.competition.update({
- where: { id },
- data,
- })
+ const competition = await ctx.prisma.competition.update({
+ where: { id },
+ data,
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'UPDATE',
- entityType: 'Competition',
- entityId: id,
- detailsJson: {
- changes: data,
- previous: {
- name: previous.name,
- status: previous.status,
- slug: previous.slug,
- },
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE',
+ entityType: 'Competition',
+ entityId: id,
+ detailsJson: {
+ changes: data,
+ previous: {
+ name: previous.name,
+ status: previous.status,
+ slug: previous.slug,
},
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return competition
@@ -216,24 +208,20 @@ export const competitionRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const competition = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.competition.update({
- where: { id: input.id },
- data: { status: 'ARCHIVED' },
- })
+ const competition = await ctx.prisma.competition.update({
+ where: { id: input.id },
+ data: { status: 'ARCHIVED' },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'Competition',
- entityId: input.id,
- detailsJson: { action: 'archived' },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'Competition',
+ entityId: input.id,
+ detailsJson: { action: 'archived' },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return competition
diff --git a/src/server/routers/decision.ts b/src/server/routers/decision.ts
index dbfaaab..dddbe52 100644
--- a/src/server/routers/decision.ts
+++ b/src/server/routers/decision.ts
@@ -97,22 +97,23 @@ export const decisionRouter = router({
snapshotJson: previousValue as Prisma.InputJsonValue,
},
})
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DECISION_OVERRIDE',
- entityType: input.entityType,
- entityId: input.entityId,
- detailsJson: {
- reasonCode: input.reasonCode,
- reasonText: input.reasonText,
- previousState: previousValue.state,
- newState: input.newValue.state,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Audit outside transaction so failures don't roll back the override
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DECISION_OVERRIDE',
+ entityType: input.entityType,
+ entityId: input.entityId,
+ detailsJson: {
+ reasonCode: input.reasonCode,
+ reasonText: input.reasonText,
+ previousState: previousValue.state,
+ newState: input.newValue.state,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
break
}
@@ -161,21 +162,22 @@ export const decisionRouter = router({
} as Prisma.InputJsonValue,
},
})
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DECISION_OVERRIDE',
- entityType: input.entityType,
- entityId: input.entityId,
- detailsJson: {
- reasonCode: input.reasonCode,
- previousOutcome: (previousValue as Record).outcome,
- newOutcome,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Audit outside transaction so failures don't roll back the override
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DECISION_OVERRIDE',
+ entityType: input.entityType,
+ entityId: input.entityId,
+ detailsJson: {
+ reasonCode: input.reasonCode,
+ previousOutcome: (previousValue as Record).outcome,
+ newOutcome,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
break
}
@@ -229,21 +231,22 @@ export const decisionRouter = router({
} as Prisma.InputJsonValue,
},
})
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DECISION_OVERRIDE',
- entityType: input.entityType,
- entityId: input.entityId,
- detailsJson: {
- reasonCode: input.reasonCode,
- previousEligible: previousValue.eligible,
- newEligible,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Audit outside transaction so failures don't roll back the override
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DECISION_OVERRIDE',
+ entityType: input.entityType,
+ entityId: input.entityId,
+ detailsJson: {
+ reasonCode: input.reasonCode,
+ previousEligible: previousValue.eligible,
+ newEligible,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
break
}
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index 4ab604d..dc10969 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -517,26 +517,27 @@ export const fileRouter = router({
data: { replacedById: newFile.id },
})
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'REPLACE_FILE',
- entityType: 'ProjectFile',
- entityId: newFile.id,
- detailsJson: {
- projectId: input.projectId,
- oldFileId: input.oldFileId,
- oldVersion: oldFile.version,
- newVersion: newFile.version,
- fileName: input.fileName,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return newFile
})
+ // Audit outside transaction so failures don't roll back the file replacement
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'REPLACE_FILE',
+ entityType: 'ProjectFile',
+ entityId: result.id,
+ detailsJson: {
+ projectId: input.projectId,
+ oldFileId: input.oldFileId,
+ oldVersion: oldFile.version,
+ newVersion: result.version,
+ fileName: input.fileName,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return result
}),
diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts
index a65e079..a27dd7f 100644
--- a/src/server/routers/juryGroup.ts
+++ b/src/server/routers/juryGroup.ts
@@ -36,26 +36,23 @@ export const juryGroupRouter = router({
const { defaultCategoryQuotas, ...rest } = input
- const juryGroup = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.juryGroup.create({
- data: {
- ...rest,
- defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
- },
- })
+ const juryGroup = await ctx.prisma.juryGroup.create({
+ data: {
+ ...rest,
+ defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'JuryGroup',
- entityId: created.id,
- detailsJson: { name: input.name, competitionId: input.competitionId },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ // Audit outside transaction so failures don't roll back the create
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'JuryGroup',
+ entityId: juryGroup.id,
+ detailsJson: { name: input.name, competitionId: input.competitionId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return juryGroup
@@ -187,39 +184,36 @@ export const juryGroupRouter = router({
})
}
- const member = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.juryGroupMember.create({
- data: {
- juryGroupId: input.juryGroupId,
- userId: input.userId,
- role: input.role,
- maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
- capModeOverride: input.capModeOverride ?? undefined,
- categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
- preferredStartupRatio: input.preferredStartupRatio ?? undefined,
- availabilityNotes: input.availabilityNotes ?? undefined,
- },
- include: {
- user: { select: { id: true, name: true, email: true, role: true } },
- },
- })
+ const member = await ctx.prisma.juryGroupMember.create({
+ data: {
+ juryGroupId: input.juryGroupId,
+ userId: input.userId,
+ role: input.role,
+ maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
+ capModeOverride: input.capModeOverride ?? undefined,
+ categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
+ preferredStartupRatio: input.preferredStartupRatio ?? undefined,
+ availabilityNotes: input.availabilityNotes ?? undefined,
+ },
+ include: {
+ user: { select: { id: true, name: true, email: true, role: true } },
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'JuryGroupMember',
- entityId: created.id,
- detailsJson: {
- juryGroupId: input.juryGroupId,
- addedUserId: input.userId,
- role: input.role,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ // Audit outside transaction so failures don't roll back the member add
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'JuryGroupMember',
+ entityId: member.id,
+ detailsJson: {
+ juryGroupId: input.juryGroupId,
+ addedUserId: input.userId,
+ role: input.role,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return member
@@ -231,31 +225,28 @@ export const juryGroupRouter = router({
removeMember: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const member = await ctx.prisma.$transaction(async (tx) => {
- const existing = await tx.juryGroupMember.findUniqueOrThrow({
- where: { id: input.id },
- })
-
- await tx.juryGroupMember.delete({ where: { id: input.id } })
-
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'JuryGroupMember',
- entityId: input.id,
- detailsJson: {
- juryGroupId: existing.juryGroupId,
- removedUserId: existing.userId,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return existing
+ const existing = await ctx.prisma.juryGroupMember.findUniqueOrThrow({
+ where: { id: input.id },
})
- return member
+ await ctx.prisma.juryGroupMember.delete({ where: { id: input.id } })
+
+ // Audit outside transaction so failures don't roll back the member removal
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'JuryGroupMember',
+ entityId: input.id,
+ detailsJson: {
+ juryGroupId: existing.juryGroupId,
+ removedUserId: existing.userId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return existing
}),
/**
diff --git a/src/server/routers/live.ts b/src/server/routers/live.ts
index 6ba7113..4bb8936 100644
--- a/src/server/routers/live.ts
+++ b/src/server/routers/live.ts
@@ -72,24 +72,25 @@ export const liveRouter = router({
},
})
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_SESSION_STARTED',
- entityType: 'Round',
- entityId: input.roundId,
- detailsJson: {
- sessionId: created.sessionId,
- projectCount: input.projectOrder.length,
- firstProjectId: input.projectOrder[0],
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return created
})
+ // Audit outside transaction so failures don't roll back the session start
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_SESSION_STARTED',
+ entityType: 'Round',
+ entityId: input.roundId,
+ detailsJson: {
+ sessionId: cursor.sessionId,
+ projectCount: input.projectOrder.length,
+ firstProjectId: input.projectOrder[0],
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return cursor
}),
@@ -123,30 +124,27 @@ export const liveRouter = router({
})
}
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.liveProgressCursor.update({
- where: { id: cursor.id },
- data: {
- activeProjectId: input.projectId,
- activeOrderIndex: index,
- },
- })
+ const updated = await ctx.prisma.liveProgressCursor.update({
+ where: { id: cursor.id },
+ data: {
+ activeProjectId: input.projectId,
+ activeOrderIndex: index,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_ACTIVE_PROJECT_SET',
- entityType: 'LiveProgressCursor',
- entityId: cursor.id,
- detailsJson: {
- projectId: input.projectId,
- orderIndex: index,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the project set
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_ACTIVE_PROJECT_SET',
+ entityType: 'LiveProgressCursor',
+ entityId: cursor.id,
+ detailsJson: {
+ projectId: input.projectId,
+ orderIndex: index,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
@@ -182,31 +180,28 @@ export const liveRouter = router({
const targetProjectId = projectOrder[input.index]
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.liveProgressCursor.update({
- where: { id: cursor.id },
- data: {
- activeProjectId: targetProjectId,
- activeOrderIndex: input.index,
- },
- })
+ const updated = await ctx.prisma.liveProgressCursor.update({
+ where: { id: cursor.id },
+ data: {
+ activeProjectId: targetProjectId,
+ activeOrderIndex: input.index,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_JUMP',
- entityType: 'LiveProgressCursor',
- entityId: cursor.id,
- detailsJson: {
- fromIndex: cursor.activeOrderIndex,
- toIndex: input.index,
- projectId: targetProjectId,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the jump
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_JUMP',
+ entityType: 'LiveProgressCursor',
+ entityId: cursor.id,
+ detailsJson: {
+ fromIndex: cursor.activeOrderIndex,
+ toIndex: input.index,
+ projectId: targetProjectId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
@@ -255,22 +250,23 @@ export const liveRouter = router({
},
})
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_REORDER',
- entityType: 'LiveProgressCursor',
- entityId: cursor.id,
- detailsJson: {
- projectCount: input.projectOrder.length,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return updatedCursor
})
+ // Audit outside transaction so failures don't roll back the reorder
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_REORDER',
+ entityType: 'LiveProgressCursor',
+ entityId: cursor.id,
+ detailsJson: {
+ projectCount: input.projectOrder.length,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return updated
}),
@@ -291,24 +287,21 @@ export const liveRouter = router({
})
}
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.liveProgressCursor.update({
- where: { id: cursor.id },
- data: { isPaused: true },
- })
+ const updated = await ctx.prisma.liveProgressCursor.update({
+ where: { id: cursor.id },
+ data: { isPaused: true },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_PAUSED',
- entityType: 'LiveProgressCursor',
- entityId: cursor.id,
- detailsJson: { activeProjectId: cursor.activeProjectId },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the pause
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_PAUSED',
+ entityType: 'LiveProgressCursor',
+ entityId: cursor.id,
+ detailsJson: { activeProjectId: cursor.activeProjectId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
@@ -331,24 +324,21 @@ export const liveRouter = router({
})
}
- const updated = await ctx.prisma.$transaction(async (tx) => {
- const result = await tx.liveProgressCursor.update({
- where: { id: cursor.id },
- data: { isPaused: false },
- })
+ const updated = await ctx.prisma.liveProgressCursor.update({
+ where: { id: cursor.id },
+ data: { isPaused: false },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'LIVE_RESUMED',
- entityType: 'LiveProgressCursor',
- entityId: cursor.id,
- detailsJson: { activeProjectId: cursor.activeProjectId },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return result
+ // Audit outside transaction so failures don't roll back the resume
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'LIVE_RESUMED',
+ entityType: 'LiveProgressCursor',
+ entityId: cursor.id,
+ detailsJson: { activeProjectId: cursor.activeProjectId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return updated
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts
index df77ea7..c8e4fbb 100644
--- a/src/server/routers/mentor.ts
+++ b/src/server/routers/mentor.ts
@@ -128,54 +128,51 @@ export const mentorRouter = router({
where: { id: input.mentorId },
})
- // Create assignment + audit log in transaction
- const assignment = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.mentorAssignment.create({
- data: {
- projectId: input.projectId,
- mentorId: input.mentorId,
- method: input.method,
- assignedBy: ctx.user.id,
- aiConfidenceScore: input.aiConfidenceScore,
- expertiseMatchScore: input.expertiseMatchScore,
- aiReasoning: input.aiReasoning,
- },
- include: {
- mentor: {
- select: {
- id: true,
- name: true,
- email: true,
- expertiseTags: true,
- },
- },
- project: {
- select: {
- id: true,
- title: true,
- },
+ // Create assignment
+ const assignment = await ctx.prisma.mentorAssignment.create({
+ data: {
+ projectId: input.projectId,
+ mentorId: input.mentorId,
+ method: input.method,
+ assignedBy: ctx.user.id,
+ aiConfidenceScore: input.aiConfidenceScore,
+ expertiseMatchScore: input.expertiseMatchScore,
+ aiReasoning: input.aiReasoning,
+ },
+ include: {
+ mentor: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ expertiseTags: true,
},
},
- })
-
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'MENTOR_ASSIGN',
- entityType: 'MentorAssignment',
- entityId: created.id,
- detailsJson: {
- projectId: input.projectId,
- projectTitle: created.project.title,
- mentorId: input.mentorId,
- mentorName: created.mentor.name,
- method: input.method,
+ project: {
+ select: {
+ id: true,
+ title: true,
+ },
},
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ },
+ })
- return created
+ // Audit outside transaction so failures don't roll back the assignment
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'MENTOR_ASSIGN',
+ entityType: 'MentorAssignment',
+ entityId: assignment.id,
+ detailsJson: {
+ projectId: input.projectId,
+ projectTitle: assignment.project.title,
+ mentorId: input.mentorId,
+ mentorName: assignment.mentor.name,
+ method: input.method,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
// Get team lead info for mentor notification
@@ -382,27 +379,26 @@ export const mentorRouter = router({
})
}
- // Delete assignment + audit log in transaction
- await ctx.prisma.$transaction(async (tx) => {
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'MENTOR_UNASSIGN',
- entityType: 'MentorAssignment',
- entityId: assignment.id,
- detailsJson: {
- projectId: input.projectId,
- projectTitle: assignment.project.title,
- mentorId: assignment.mentor.id,
- mentorName: assignment.mentor.name,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Delete assignment
+ await ctx.prisma.mentorAssignment.delete({
+ where: { projectId: input.projectId },
+ })
- await tx.mentorAssignment.delete({
- where: { projectId: input.projectId },
- })
+ // Audit outside transaction so failures don't roll back the unassignment
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'MENTOR_UNASSIGN',
+ entityType: 'MentorAssignment',
+ entityId: assignment.id,
+ detailsJson: {
+ projectId: input.projectId,
+ projectTitle: assignment.project.title,
+ mentorId: assignment.mentor.id,
+ mentorName: assignment.mentor.name,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { success: true }
diff --git a/src/server/routers/project-pool.ts b/src/server/routers/project-pool.ts
index 62c8805..b66f3b5 100644
--- a/src/server/routers/project-pool.ts
+++ b/src/server/routers/project-pool.ts
@@ -174,24 +174,24 @@ export const projectPoolRouter = router({
})),
})
- // Create audit log
- await logAudit({
- prisma: tx,
- userId: ctx.user?.id,
- action: 'BULK_ASSIGN_TO_ROUND',
- entityType: 'Project',
- detailsJson: {
- roundId,
- projectCount: projectIds.length,
- projectIds,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return updatedProjects
})
+ // Audit outside transaction so failures don't roll back the assignment
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user?.id,
+ action: 'BULK_ASSIGN_TO_ROUND',
+ entityType: 'Project',
+ detailsJson: {
+ roundId,
+ projectCount: projectIds.length,
+ projectIds,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return {
success: true,
assignedCount: result.count,
@@ -258,24 +258,25 @@ export const projectPoolRouter = router({
})),
})
- await logAudit({
- prisma: tx,
- userId: ctx.user?.id,
- action: 'BULK_ASSIGN_ALL_TO_ROUND',
- entityType: 'Project',
- detailsJson: {
- roundId,
- programId,
- competitionCategory: competitionCategory || 'ALL',
- projectCount: projectIds.length,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return updated
})
+ // Audit outside transaction so failures don't roll back the assignment
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user?.id,
+ action: 'BULK_ASSIGN_ALL_TO_ROUND',
+ entityType: 'Project',
+ detailsJson: {
+ roundId,
+ programId,
+ competitionCategory: competitionCategory || 'ALL',
+ projectCount: projectIds.length,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return { success: true, assignedCount: result.count, roundId }
}),
})
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index 8f69dae..936bb8e 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -590,24 +590,25 @@ export const projectRouter = router({
}
}
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'Project',
- entityId: created.id,
- detailsJson: {
- title: input.title,
- programId: resolvedProgramId,
- teamMembersCount: teamMembersInput?.length || 0,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return { project: created, membersToInvite: inviteList }
})
+ // Audit outside transaction so failures don't roll back the project creation
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'Project',
+ entityId: project.id,
+ detailsJson: {
+ title: input.title,
+ programId: resolvedProgramId,
+ teamMembersCount: teamMembersInput?.length || 0,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
// Send invite emails outside the transaction (never fail project creation)
if (membersToInvite.length > 0) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
@@ -782,26 +783,25 @@ export const projectRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const project = await ctx.prisma.$transaction(async (tx) => {
- const target = await tx.project.findUniqueOrThrow({
- where: { id: input.id },
- select: { id: true, title: true },
- })
+ const target = await ctx.prisma.project.findUniqueOrThrow({
+ where: { id: input.id },
+ select: { id: true, title: true },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'Project',
- entityId: input.id,
- detailsJson: { title: target.title },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ const project = await ctx.prisma.project.delete({
+ where: { id: input.id },
+ })
- return tx.project.delete({
- where: { id: input.id },
- })
+ // Audit outside transaction so failures don't roll back the delete
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'Project',
+ entityId: input.id,
+ detailsJson: { title: target.title },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return project
@@ -829,24 +829,23 @@ export const projectRouter = router({
})
}
- const result = await ctx.prisma.$transaction(async (tx) => {
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'BULK_DELETE',
- entityType: 'Project',
- detailsJson: {
- count: projects.length,
- titles: projects.map((p) => p.title),
- ids: projects.map((p) => p.id),
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ const result = await ctx.prisma.project.deleteMany({
+ where: { id: { in: projects.map((p) => p.id) } },
+ })
- return tx.project.deleteMany({
- where: { id: { in: projects.map((p) => p.id) } },
- })
+ // Audit outside transaction so failures don't roll back the bulk delete
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'BULK_DELETE',
+ entityType: 'Project',
+ detailsJson: {
+ count: projects.length,
+ titles: projects.map((p) => p.title),
+ ids: projects.map((p) => p.id),
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { deleted: result.count }
@@ -996,19 +995,20 @@ export const projectRouter = router({
})
}
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'BULK_UPDATE_STATUS',
- entityType: 'Project',
- detailsJson: { ids: matchingIds, status: input.status, count: result.count },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return result
})
+ // Audit outside transaction so failures don't roll back the bulk update
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'BULK_UPDATE_STATUS',
+ entityType: 'Project',
+ detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
// Notify project teams based on status
if (projects.length > 0) {
const notificationConfig: Record<
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts
index ac8388a..8a93eab 100644
--- a/src/server/routers/round.ts
+++ b/src/server/routers/round.ts
@@ -54,39 +54,35 @@ export const roundRouter = router({
? validateRoundConfig(input.roundType, input.configJson)
: defaultRoundConfig(input.roundType)
- const round = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.round.create({
- data: {
- competitionId: input.competitionId,
- name: input.name,
- slug: input.slug,
- roundType: input.roundType,
- sortOrder: input.sortOrder,
- configJson: config as unknown as Prisma.InputJsonValue,
- windowOpenAt: input.windowOpenAt ?? undefined,
- windowCloseAt: input.windowCloseAt ?? undefined,
- juryGroupId: input.juryGroupId ?? undefined,
- submissionWindowId: input.submissionWindowId ?? undefined,
- purposeKey: input.purposeKey ?? undefined,
- },
- })
+ const round = await ctx.prisma.round.create({
+ data: {
+ competitionId: input.competitionId,
+ name: input.name,
+ slug: input.slug,
+ roundType: input.roundType,
+ sortOrder: input.sortOrder,
+ configJson: config as unknown as Prisma.InputJsonValue,
+ windowOpenAt: input.windowOpenAt ?? undefined,
+ windowCloseAt: input.windowCloseAt ?? undefined,
+ juryGroupId: input.juryGroupId ?? undefined,
+ submissionWindowId: input.submissionWindowId ?? undefined,
+ purposeKey: input.purposeKey ?? undefined,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'Round',
- entityId: created.id,
- detailsJson: {
- name: input.name,
- roundType: input.roundType,
- competitionId: input.competitionId,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'Round',
+ entityId: round.id,
+ detailsJson: {
+ name: input.name,
+ roundType: input.roundType,
+ competitionId: input.competitionId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return round
@@ -145,42 +141,38 @@ export const roundRouter = router({
.mutation(async ({ ctx, input }) => {
const { id, configJson, ...data } = input
- const round = await ctx.prisma.$transaction(async (tx) => {
- const existing = await tx.round.findUniqueOrThrow({ where: { id } })
+ const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
- // If configJson provided, validate it against the round type
- let validatedConfig: Prisma.InputJsonValue | undefined
- if (configJson) {
- const parsed = validateRoundConfig(existing.roundType, configJson)
- validatedConfig = parsed as unknown as Prisma.InputJsonValue
- }
+ // If configJson provided, validate it against the round type
+ let validatedConfig: Prisma.InputJsonValue | undefined
+ if (configJson) {
+ const parsed = validateRoundConfig(existing.roundType, configJson)
+ validatedConfig = parsed as unknown as Prisma.InputJsonValue
+ }
- const updated = await tx.round.update({
- where: { id },
- data: {
- ...data,
- ...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
+ const round = await ctx.prisma.round.update({
+ where: { id },
+ data: {
+ ...data,
+ ...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
+ },
+ })
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE',
+ entityType: 'Round',
+ entityId: id,
+ detailsJson: {
+ changes: input,
+ previous: {
+ name: existing.name,
+ status: existing.status,
},
- })
-
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'UPDATE',
- entityType: 'Round',
- entityId: id,
- detailsJson: {
- changes: input,
- previous: {
- name: existing.name,
- status: existing.status,
- },
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return round
@@ -213,30 +205,26 @@ export const roundRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const round = await ctx.prisma.$transaction(async (tx) => {
- const existing = await tx.round.findUniqueOrThrow({ where: { id: input.id } })
+ const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } })
- await tx.round.delete({ where: { id: input.id } })
+ await ctx.prisma.round.delete({ where: { id: input.id } })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'Round',
- entityId: input.id,
- detailsJson: {
- name: existing.name,
- roundType: existing.roundType,
- competitionId: existing.competitionId,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return existing
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'Round',
+ entityId: input.id,
+ detailsJson: {
+ name: existing.name,
+ roundType: existing.roundType,
+ competitionId: existing.competitionId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
- return round
+ return existing
}),
// =========================================================================
@@ -261,33 +249,29 @@ export const roundRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const window = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.submissionWindow.create({
- data: {
- competitionId: input.competitionId,
- name: input.name,
- slug: input.slug,
- roundNumber: input.roundNumber,
- windowOpenAt: input.windowOpenAt,
- windowCloseAt: input.windowCloseAt,
- deadlinePolicy: input.deadlinePolicy,
- graceHours: input.graceHours,
- lockOnClose: input.lockOnClose,
- },
- })
+ const window = await ctx.prisma.submissionWindow.create({
+ data: {
+ competitionId: input.competitionId,
+ name: input.name,
+ slug: input.slug,
+ roundNumber: input.roundNumber,
+ windowOpenAt: input.windowOpenAt,
+ windowCloseAt: input.windowCloseAt,
+ deadlinePolicy: input.deadlinePolicy,
+ graceHours: input.graceHours,
+ lockOnClose: input.lockOnClose,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'SubmissionWindow',
- entityId: created.id,
- detailsJson: { name: input.name, competitionId: input.competitionId },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'SubmissionWindow',
+ entityId: window.id,
+ detailsJson: { name: input.name, competitionId: input.competitionId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return window
@@ -313,22 +297,19 @@ export const roundRouter = router({
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
- const window = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.submissionWindow.update({
- where: { id },
- data,
- })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'UPDATE',
- entityType: 'SubmissionWindow',
- entityId: id,
- detailsJson: data,
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
- return updated
+ const window = await ctx.prisma.submissionWindow.update({
+ where: { id },
+ data,
+ })
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE',
+ entityType: 'SubmissionWindow',
+ entityId: id,
+ detailsJson: data,
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return window
}),
@@ -350,18 +331,16 @@ export const roundRouter = router({
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
})
}
- await ctx.prisma.$transaction(async (tx) => {
- await tx.submissionWindow.delete({ where: { id: input.id } })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'SubmissionWindow',
- entityId: input.id,
- detailsJson: { name: window.name },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ await ctx.prisma.submissionWindow.delete({ where: { id: input.id } })
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'SubmissionWindow',
+ entityId: input.id,
+ detailsJson: { name: window.name },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { success: true }
}),
diff --git a/src/server/routers/roundEngine.ts b/src/server/routers/roundEngine.ts
index 59962eb..4787bef 100644
--- a/src/server/routers/roundEngine.ts
+++ b/src/server/routers/roundEngine.ts
@@ -140,4 +140,109 @@ export const roundEngineRouter = router({
.query(async ({ ctx, input }) => {
return getProjectRoundState(input.projectId, input.roundId, ctx.prisma)
}),
+
+ /**
+ * Remove a project from a round (and all subsequent rounds in that competition).
+ * The project remains in all prior rounds.
+ */
+ removeFromRound: adminProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ roundId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Get the round to know its competition and sort order
+ const round = await ctx.prisma.round.findUniqueOrThrow({
+ where: { id: input.roundId },
+ select: { id: true, competitionId: true, sortOrder: true },
+ })
+
+ // Find all rounds at this sort order or later in the same competition
+ const roundsToRemoveFrom = await ctx.prisma.round.findMany({
+ where: {
+ competitionId: round.competitionId,
+ sortOrder: { gte: round.sortOrder },
+ },
+ select: { id: true },
+ })
+
+ const roundIds = roundsToRemoveFrom.map((r) => r.id)
+
+ // Delete ProjectRoundState entries for this project in all affected rounds
+ const deleted = await ctx.prisma.projectRoundState.deleteMany({
+ where: {
+ projectId: input.projectId,
+ roundId: { in: roundIds },
+ },
+ })
+
+ // Check if the project is still in any round at all
+ const remainingStates = await ctx.prisma.projectRoundState.count({
+ where: { projectId: input.projectId },
+ })
+
+ // If no longer in any round, reset project status back to SUBMITTED
+ if (remainingStates === 0) {
+ await ctx.prisma.project.update({
+ where: { id: input.projectId },
+ data: { status: 'SUBMITTED' },
+ })
+ }
+
+ return { success: true, removedFromRounds: deleted.count }
+ }),
+
+ /**
+ * Batch remove projects from a round (and all subsequent rounds).
+ */
+ batchRemoveFromRound: adminProcedure
+ .input(
+ z.object({
+ projectIds: z.array(z.string()).min(1),
+ roundId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const round = await ctx.prisma.round.findUniqueOrThrow({
+ where: { id: input.roundId },
+ select: { id: true, competitionId: true, sortOrder: true },
+ })
+
+ const roundsToRemoveFrom = await ctx.prisma.round.findMany({
+ where: {
+ competitionId: round.competitionId,
+ sortOrder: { gte: round.sortOrder },
+ },
+ select: { id: true },
+ })
+
+ const roundIds = roundsToRemoveFrom.map((r) => r.id)
+
+ const deleted = await ctx.prisma.projectRoundState.deleteMany({
+ where: {
+ projectId: { in: input.projectIds },
+ roundId: { in: roundIds },
+ },
+ })
+
+ // For projects with no remaining round states, reset to SUBMITTED
+ const projectsStillInRounds = await ctx.prisma.projectRoundState.findMany({
+ where: { projectId: { in: input.projectIds } },
+ select: { projectId: true },
+ distinct: ['projectId'],
+ })
+ const stillInRoundIds = new Set(projectsStillInRounds.map((p) => p.projectId))
+ const orphanedIds = input.projectIds.filter((id) => !stillInRoundIds.has(id))
+
+ if (orphanedIds.length > 0) {
+ await ctx.prisma.project.updateMany({
+ where: { id: { in: orphanedIds } },
+ data: { status: 'SUBMITTED' },
+ })
+ }
+
+ return { success: true, removedCount: deleted.count }
+ }),
})
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts
index 3ec3188..690c7b7 100644
--- a/src/server/routers/specialAward.ts
+++ b/src/server/routers/specialAward.ts
@@ -106,37 +106,34 @@ export const specialAwardRouter = router({
_max: { sortOrder: true },
})
- const award = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.specialAward.create({
- data: {
- programId: input.programId,
- name: input.name,
- description: input.description,
- criteriaText: input.criteriaText,
- useAiEligibility: input.useAiEligibility ?? true,
- scoringMode: input.scoringMode,
- maxRankedPicks: input.maxRankedPicks,
- autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
- competitionId: input.competitionId,
- evaluationRoundId: input.evaluationRoundId,
- juryGroupId: input.juryGroupId,
- eligibilityMode: input.eligibilityMode,
- sortOrder: (maxOrder._max.sortOrder || 0) + 1,
- },
- })
+ const award = await ctx.prisma.specialAward.create({
+ data: {
+ programId: input.programId,
+ name: input.name,
+ description: input.description,
+ criteriaText: input.criteriaText,
+ useAiEligibility: input.useAiEligibility ?? true,
+ scoringMode: input.scoringMode,
+ maxRankedPicks: input.maxRankedPicks,
+ autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
+ competitionId: input.competitionId,
+ evaluationRoundId: input.evaluationRoundId,
+ juryGroupId: input.juryGroupId,
+ eligibilityMode: input.eligibilityMode,
+ sortOrder: (maxOrder._max.sortOrder || 0) + 1,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'SpecialAward',
- entityId: created.id,
- detailsJson: { name: input.name, scoringMode: input.scoringMode },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ // Audit outside transaction so failures don't roll back the create
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'SpecialAward',
+ entityId: award.id,
+ detailsJson: { name: input.name, scoringMode: input.scoringMode },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return award
@@ -190,18 +187,17 @@ export const specialAwardRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- await ctx.prisma.$transaction(async (tx) => {
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'SpecialAward',
- entityId: input.id,
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ await ctx.prisma.specialAward.delete({ where: { id: input.id } })
- await tx.specialAward.delete({ where: { id: input.id } })
+ // Audit outside transaction so failures don't break the delete
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'SpecialAward',
+ entityId: input.id,
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
}),
@@ -245,32 +241,29 @@ export const specialAwardRouter = router({
}
}
- const award = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.specialAward.update({
- where: { id: input.id },
- data: updateData,
- })
+ const award = await ctx.prisma.specialAward.update({
+ where: { id: input.id },
+ data: updateData,
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'UPDATE_STATUS',
- entityType: 'SpecialAward',
- entityId: input.id,
- detailsJson: {
- previousStatus: current.status,
- newStatus: input.status,
- ...(votingStartAtUpdated && {
- votingStartAtUpdated: true,
- previousVotingStartAt: current.votingStartAt,
- newVotingStartAt: now,
- }),
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ // Audit outside transaction so failures don't break the status update
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE_STATUS',
+ entityType: 'SpecialAward',
+ entityId: input.id,
+ detailsJson: {
+ previousStatus: current.status,
+ newStatus: input.status,
+ ...(votingStartAtUpdated && {
+ votingStartAtUpdated: true,
+ previousVotingStartAt: current.votingStartAt,
+ newVotingStartAt: now,
+ }),
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return award
@@ -743,33 +736,30 @@ export const specialAwardRouter = router({
select: { winnerProjectId: true },
})
- const award = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.specialAward.update({
- where: { id: input.awardId },
- data: {
- winnerProjectId: input.projectId,
- winnerOverridden: input.overridden,
- winnerOverriddenBy: input.overridden ? ctx.user.id : null,
- },
- })
+ const award = await ctx.prisma.specialAward.update({
+ where: { id: input.awardId },
+ data: {
+ winnerProjectId: input.projectId,
+ winnerOverridden: input.overridden,
+ winnerOverriddenBy: input.overridden ? ctx.user.id : null,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'UPDATE',
- entityType: 'SpecialAward',
- entityId: input.awardId,
- detailsJson: {
- action: 'SET_AWARD_WINNER',
- previousWinner: previous.winnerProjectId,
- newWinner: input.projectId,
- overridden: input.overridden,
- },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ // Audit outside transaction so failures don't break the winner update
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE',
+ entityType: 'SpecialAward',
+ entityId: input.awardId,
+ detailsJson: {
+ action: 'SET_AWARD_WINNER',
+ previousWinner: previous.winnerProjectId,
+ newWinner: input.projectId,
+ overridden: input.overridden,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return award
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 3272fd2..9c0b016 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -194,22 +194,21 @@ export const userRouter = router({
})
}
- // Wrap audit + deletion in a transaction
- await ctx.prisma.$transaction(async (tx) => {
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE_OWN_ACCOUNT',
- entityType: 'User',
- entityId: ctx.user.id,
- detailsJson: { email: user.email },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Delete user
+ await ctx.prisma.user.delete({
+ where: { id: ctx.user.id },
+ })
- await tx.user.delete({
- where: { id: ctx.user.id },
- })
+ // Audit outside transaction so failures don't roll back the deletion
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE_OWN_ACCOUNT',
+ entityType: 'User',
+ entityId: ctx.user.id,
+ detailsJson: { email: user.email },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { success: true }
@@ -384,26 +383,23 @@ export const userRouter = router({
})
}
- const user = await ctx.prisma.$transaction(async (tx) => {
- const created = await tx.user.create({
- data: {
- ...input,
- status: 'INVITED',
- },
- })
+ const user = await ctx.prisma.user.create({
+ data: {
+ ...input,
+ status: 'INVITED',
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'User',
- entityId: created.id,
- detailsJson: { email: input.email, role: input.role },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return created
+ // Audit outside transaction so failures don't roll back the user creation
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'CREATE',
+ entityType: 'User',
+ entityId: user.id,
+ detailsJson: { email: input.email, role: input.role },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return user
@@ -486,39 +482,36 @@ export const userRouter = router({
...(normalizedEmail !== undefined && { email: normalizedEmail }),
}
- const user = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.user.update({
- where: { id },
- data: updateData,
- })
+ const user = await ctx.prisma.user.update({
+ where: { id },
+ data: updateData,
+ })
+ // Audit outside transaction so failures don't roll back the update
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPDATE',
+ entityType: 'User',
+ entityId: id,
+ detailsJson: updateData,
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ // Track role change specifically
+ if (data.role && data.role !== targetUser.role) {
await logAudit({
- prisma: tx,
+ prisma: ctx.prisma,
userId: ctx.user.id,
- action: 'UPDATE',
+ action: 'ROLE_CHANGED',
entityType: 'User',
entityId: id,
- detailsJson: updateData,
+ detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
-
- // Track role change specifically
- if (data.role && data.role !== targetUser.role) {
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'ROLE_CHANGED',
- entityType: 'User',
- entityId: id,
- detailsJson: { previousRole: targetUser.role, newRole: data.role },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
- }
-
- return updated
- })
+ }
return user
}),
@@ -537,27 +530,26 @@ export const userRouter = router({
})
}
- const user = await ctx.prisma.$transaction(async (tx) => {
- // Fetch user data before deletion for the audit log
- const target = await tx.user.findUniqueOrThrow({
- where: { id: input.id },
- select: { email: true },
- })
+ // Fetch user data before deletion for the audit log
+ const target = await ctx.prisma.user.findUniqueOrThrow({
+ where: { id: input.id },
+ select: { email: true },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'User',
- entityId: input.id,
- detailsJson: { email: target.email },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ const user = await ctx.prisma.user.delete({
+ where: { id: input.id },
+ })
- return tx.user.delete({
- where: { id: input.id },
- })
+ // Audit outside transaction so failures don't roll back the deletion
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'DELETE',
+ entityType: 'User',
+ entityId: input.id,
+ detailsJson: { email: target.email },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return user
@@ -1147,20 +1139,21 @@ export const userRouter = router({
}
}
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'COMPLETE_ONBOARDING',
- entityType: 'User',
- entityId: ctx.user.id,
- detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
return updated
})
+ // Audit outside transaction so failures don't roll back the onboarding
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'COMPLETE_ONBOARDING',
+ entityType: 'User',
+ entityId: ctx.user.id,
+ detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
return user
}),
@@ -1265,29 +1258,26 @@ export const userRouter = router({
// Hash the password
const passwordHash = await hashPassword(input.password)
- // Update user with new password + audit in transaction
- const user = await ctx.prisma.$transaction(async (tx) => {
- const updated = await tx.user.update({
- where: { id: ctx.user.id },
- data: {
- passwordHash,
- passwordSetAt: new Date(),
- mustSetPassword: false,
- },
- })
+ // Update user with new password
+ const user = await ctx.prisma.user.update({
+ where: { id: ctx.user.id },
+ data: {
+ passwordHash,
+ passwordSetAt: new Date(),
+ mustSetPassword: false,
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'PASSWORD_SET',
- entityType: 'User',
- entityId: ctx.user.id,
- detailsJson: { timestamp: new Date().toISOString() },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
-
- return updated
+ // Audit outside transaction so failures don't roll back the password set
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'PASSWORD_SET',
+ entityType: 'User',
+ entityId: ctx.user.id,
+ detailsJson: { timestamp: new Date().toISOString() },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { success: true, email: user.email }
@@ -1348,26 +1338,25 @@ export const userRouter = router({
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
- // Update user with new password + audit in transaction
- await ctx.prisma.$transaction(async (tx) => {
- await tx.user.update({
- where: { id: ctx.user.id },
- data: {
- passwordHash,
- passwordSetAt: new Date(),
- },
- })
+ // Update user with new password
+ await ctx.prisma.user.update({
+ where: { id: ctx.user.id },
+ data: {
+ passwordHash,
+ passwordSetAt: new Date(),
+ },
+ })
- await logAudit({
- prisma: tx,
- userId: ctx.user.id,
- action: 'PASSWORD_CHANGED',
- entityType: 'User',
- entityId: ctx.user.id,
- detailsJson: { timestamp: new Date().toISOString() },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- })
+ // Audit outside transaction so failures don't roll back the password change
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'PASSWORD_CHANGED',
+ entityType: 'User',
+ entityId: ctx.user.id,
+ detailsJson: { timestamp: new Date().toISOString() },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
})
return { success: true }