-
+
+ {[1, 2, 3, 4].map((i) => )}
+
-
+
)
}
@@ -187,276 +286,833 @@ export default function RoundDetailPage() {
return (
-
-
+
+
Round Not Found
-
The requested round does not exist
+
This round does not exist.
)
}
- const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
- const canActivate = round.status === 'ROUND_DRAFT'
- const canClose = round.status === 'ROUND_ACTIVE'
- const canArchive = round.status === 'ROUND_CLOSED'
+ const status = round.status as keyof typeof roundStatusConfig
+ const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
+ const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
+
+ // ── Readiness checklist ────────────────────────────────────────────────
+ const readinessItems = [
+ {
+ label: 'Projects assigned',
+ ready: projectCount > 0,
+ detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet',
+ action: projectCount === 0 ? poolLink : undefined,
+ actionLabel: 'Assign Projects',
+ },
+ ...((isEvaluation || isFiltering)
+ ? [{
+ label: 'Jury group set',
+ ready: !!juryGroup,
+ detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned',
+ action: undefined as Route | undefined,
+ actionLabel: undefined as string | undefined,
+ }]
+ : []),
+ {
+ label: 'Dates configured',
+ ready: !!round.windowOpenAt && !!round.windowCloseAt,
+ detail:
+ round.windowOpenAt && round.windowCloseAt
+ ? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}`
+ : 'No dates set \u2014 configure in Config tab',
+ action: undefined as Route | undefined,
+ actionLabel: undefined as string | undefined,
+ },
+ {
+ label: 'File requirements set',
+ ready: (fileRequirements?.length ?? 0) > 0,
+ detail:
+ (fileRequirements?.length ?? 0) > 0
+ ? `${fileRequirements?.length} requirement(s)`
+ : 'No file requirements \u2014 configure in Config tab',
+ action: undefined as Route | undefined,
+ actionLabel: undefined as string | undefined,
+ },
+ ]
+ const readyCount = readinessItems.filter((i) => i.ready).length
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // Render
+ // ═════════════════════════════════════════════════════════════════════════
return (
- {/* Header */}
-
-
-
-
-
+ {/* ===== HEADER ===== */}
+
+
-
-
-
-
{round.name}
-
- {round.roundType.replace('_', ' ')}
+
+
{round.name}
+
+ {typeCfg.label}
- {/* Status Dropdown */}
+ {/* Status dropdown */}
-
+
+
{statusCfg.label}
-
+
- {canActivate && (
- setConfirmAction('activate')}>
+ {status === 'ROUND_DRAFT' && (
+ activateMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
Activate Round
)}
- {canClose && (
- setConfirmAction('close')}>
+ {status === 'ROUND_ACTIVE' && (
+ closeMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
Close Round
)}
- {canArchive && (
+ {status === 'ROUND_CLOSED' && (
<>
+ activateMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
+
+ Reactivate Round
+
- setConfirmAction('archive')}>
+ archiveMutation.mutate({ roundId })}
+ disabled={isTransitioning}
+ >
Archive Round
>
)}
- {!canActivate && !canClose && !canArchive && (
- No actions available
+ {isTransitioning && (
+
+
+ Updating...
+
)}
- {round.slug}
+ {typeCfg.description}
+
-
- {hasChanges && (
-
- {updateMutation.isPending ? (
-
- ) : (
-
- )}
- Save Changes
-
- )}
-
+ {/* Action buttons */}
+
+ {hasChanges && (
+
+ {updateMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Save Config
+
+ )}
+
+
+
+ Project Pool
+
+
- {/* Summary Stats */}
-
-
-
-
-
-
-
-
-
{round._count?.projectRoundStates ?? 0}
-
Projects
+ {/* ===== STATS BAR ===== */}
+
+ {/* Projects */}
+
+
+
+ {projectCount}
+
+ {Object.entries(stateCounts).map(([state, count]) => (
+
+ {String(count)} {state.toLowerCase().replace('_', ' ')}
+
+ ))}
+
-
-
-
-
-
-
-
-
{round.juryGroup?.members?.length ?? 0}
-
Jury Members
-
+ {/* Jury (with inline group selector) */}
+
+
+
+
+ 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
+ >
+ )}
-
-
-
-
-
-
-
- {round.juryGroup ? (
- <>
-
{round.juryGroup.name}
-
Jury Group
- >
- ) : (
- <>
-
No jury assigned
-
Jury Group
- >
- )}
-
+ {/* Window */}
+
+
+
+
+ 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
+ >
+ )}
-
-
-
-
-
-
-
- {round.windowOpenAt || round.windowCloseAt ? (
- <>
-
- {round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()}
- {round.windowOpenAt && round.windowCloseAt && ' - '}
- {round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()}
-
-
Schedule
- >
- ) : (
- <>
-
Not scheduled
-
Schedule
- >
- )}
-
+ {/* Advancement */}
+
+
+
+
+ Advancement
+ {round.advancementRules && round.advancementRules.length > 0 ? (
+ <>
+ {round.advancementRules.length}
+
+ {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
+
+ >
+ ) : (
+ <>
+ —
+ Admin selection
+ >
+ )}
- {/* Schedule Editor */}
-
-
-
-
-
Round Schedule
-
-
-
-
-
- {/* Tabs */}
-
-
-
- Configuration
+ {/* ===== TABS ===== */}
+
+
+
+
+ Overview
-
+
+
Projects
-
- Submission Windows
+ {isFiltering && (
+
+
+ Filtering
+
+ )}
+ {isEvaluation && (
+
+
+ Assignments
+
+ )}
+
+
+ Config
-
- Documents
+
+
+ Submissions
-
+
+
Awards
{roundAwards.length > 0 && (
-
+
{roundAwards.length}
)}
-
+ {/* ═══════════ OVERVIEW TAB ═══════════ */}
+
+ {/* Readiness Checklist */}
+
+
+
+
+ Readiness Checklist
+
+ {readyCount}/{readinessItems.length} items ready
+
+
+
+ {readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
+
+
+
+
+
+ {readinessItems.map((item) => (
+
+ {item.ready ? (
+
+ ) : (
+
+ )}
+
+
+ {item.label}
+
+
{item.detail}
+
+ {item.action && (
+
+
+ {item.actionLabel}
+
+
+ )}
+
+ ))}
+
+
+
+
+ {/* 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: manage assignments */}
+ {isEvaluation && (
+
setActiveTab('assignments')}
+ className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
+ >
+
+
+
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
+
+
+
+
+ {/* Advance projects (shown when PASSED > 0) */}
+ {passedCount > 0 && (
+
setAdvanceDialogOpen(true)}
+ className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-green-200 bg-green-50/50"
+ >
+
+
+
Advance Projects
+
+ Move {passedCount} passed project(s) to the next round
+
+
+
+ )}
+
+
+
+
+ {/* Advance Projects Confirmation Dialog */}
+
+
+
+ Advance {passedCount} project(s)?
+
+ All projects with PASSED status in this round will be moved to the next round.
+ This action creates new entries in the next round and marks current entries as completed.
+
+
+
+ Cancel
+ advanceMutation.mutate({ roundId })}
+ disabled={advanceMutation.isPending}
+ >
+ {advanceMutation.isPending && }
+ Advance Projects
+
+
+
+
+
+ {/* Round Info + Project Breakdown */}
+
+
+
+ Round Details
+
+
+
+ Type
+ {typeCfg.label}
+
+
+ Status
+ {statusCfg.label}
+
+
+ Sort Order
+ {round.sortOrder}
+
+ {round.purposeKey && (
+
+ Purpose
+ {round.purposeKey}
+
+ )}
+
+ Jury Group
+
+ {juryGroup ? juryGroup.name : '\u2014'}
+
+
+
+ Opens
+
+ {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
+
+
+
+ Closes
+
+ {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
+
+
+
+
+
+
+
+ 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)
+ return (
+
+
+ {state.toLowerCase().replace('_', ' ')}
+ {count} ({pct}%)
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ {/* ═══════════ PROJECTS TAB ═══════════ */}
+
+
+
+
+ {/* ═══════════ FILTERING TAB ═══════════ */}
+ {isFiltering && (
+
+
+
+ )}
+
+ {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
+ {isEvaluation && (
+
+ {/* Coverage Report */}
+
+
+ {/* Generate Assignments */}
+
+
+
+
+ Assignment Generation
+
+ AI-suggested jury-to-project assignments based on expertise and workload
+
+
+
setPreviewSheetOpen(true)}
+ disabled={projectCount === 0 || !juryGroup}
+ >
+
+ Generate Assignments
+
+
+
+
+ {!juryGroup && (
+
+
+ Assign a jury group first before generating assignments.
+
+ )}
+ {projectCount === 0 && (
+
+
+ Add projects to this round first.
+
+ )}
+ {juryGroup && projectCount > 0 && (
+
+ Click "Generate Assignments" to preview AI-suggested assignments.
+ You can review and execute them from the preview sheet.
+
+ )}
+
+
+
+ {/* Jury Progress + Score Distribution */}
+
+
+
+
+
+ {/* Actions: Send Reminders + Export */}
+
+
+ setExportOpen(true)}>
+
+ Export Evaluations
+
+
+
+ {/* Individual Assignments Table */}
+
+
+ {/* Unassigned Queue */}
+
+
+ {/* Assignment Preview Sheet */}
+
+
+ {/* CSV Export Dialog */}
+
+
+ )}
+
+ {/* ═══════════ CONFIG TAB ═══════════ */}
+
+ {/* General Round Settings */}
+
+
+ General Settings
+ Settings that apply to this round regardless of type
+
+
+
+
+
+ Notify on round entry
+
+
+ Send an automated email to project applicants when their project enters this round
+
+
+
{
+ handleConfigChange({ ...config, notifyOnEntry: checked })
+ }}
+ />
+
+
+
+
+ {/* Round-type-specific config */}
({ id: jg.id, name: jg.name }))}
/>
+
+ {/* Evaluation Criteria Editor (EVALUATION rounds only) */}
+ {isEvaluation && }
+
+ {/* Document Requirements */}
+
+
+ Document Requirements
+
+ Files applicants must submit for this round
+ {round.windowCloseAt && (
+ <> — due by {new Date(round.windowCloseAt).toLocaleDateString()}>
+ )}
+
+
+
+
+
+
-
-
-
-
+ {/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
-
-
-
-
-
+
+ {/* ═══════════ AWARDS TAB ═══════════ */}
{roundAwards.length === 0 ? (
+
No awards linked to this round
- Create an award and set this round as its source round to see it here
+ Create an award and set this round as its evaluation round to see it here
) : (
@@ -497,17 +1153,11 @@ export default function RoundDetailPage() {
-
- {ruleCount}
-
-
- {ruleCount === 1 ? 'rule' : 'rules'}
-
+
{ruleCount}
+
{ruleCount === 1 ? 'rule' : 'rules'}
-
- {eligibleCount}
-
+
{eligibleCount}
eligible
@@ -521,31 +1171,576 @@ export default function RoundDetailPage() {
+
+ )
+}
- {/* Lifecycle Confirmation Dialog */}
- setConfirmAction(null)}>
+// ═══════════════════════════════════════════════════════════════════════════
+// Sub-components
+// ═══════════════════════════════════════════════════════════════════════════
+
+// ── Unassigned projects queue ────────────────────────────────────────────
+
+function RoundUnassignedQueue({ roundId }: { roundId: string }) {
+ const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
+ { roundId, requiredReviews: 3 },
+ )
+
+ return (
+
+
+ Unassigned Projects
+ Projects with fewer than 3 jury assignments
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : unassigned && unassigned.length > 0 ? (
+
+ {unassigned.map((project: any) => (
+
+
+
{project.title}
+
+ {project.competitionCategory || 'No category'}
+ {project.teamName && ` \u00b7 ${project.teamName}`}
+
+
+
+ {project.assignmentCount || 0} / 3
+
+
+ ))}
+
+ ) : (
+
+ All projects have sufficient assignments
+
+ )}
+
+
+ )
+}
+
+// ── Jury Progress Table ──────────────────────────────────────────────────
+
+function JuryProgressTable({ roundId }: { roundId: string }) {
+ const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery({ roundId })
+
+ return (
+
+
+ Jury Progress
+ Evaluation completion per juror
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : !workload || workload.length === 0 ? (
+
+ No assignments yet
+
+ ) : (
+
+ {workload.map((juror) => {
+ const pct = juror.completionRate
+ const barColor = pct === 100
+ ? 'bg-emerald-500'
+ : pct >= 50
+ ? 'bg-blue-500'
+ : pct > 0
+ ? 'bg-amber-500'
+ : 'bg-gray-300'
+
+ return (
+
+
+ {juror.name}
+
+ {juror.completed}/{juror.assigned} ({pct}%)
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+// ── Score Distribution ───────────────────────────────────────────────────
+
+function ScoreDistribution({ roundId }: { roundId: string }) {
+ const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery({ roundId })
+
+ const maxCount = useMemo(() =>
+ dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
+ [dist])
+
+ return (
+
+
+ Score Distribution
+
+ {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 10 }).map((_, i) => )}
+
+ ) : !dist || dist.totalEvaluations === 0 ? (
+
+ No evaluations submitted yet
+
+ ) : (
+
+ {dist.globalDistribution.map((bucket) => {
+ const heightPct = (bucket.count / maxCount) * 100
+ return (
+
+
{bucket.count || ''}
+
+
{bucket.score}
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+// ── Send Reminders Button ────────────────────────────────────────────────
+
+function SendRemindersButton({ roundId }: { roundId: string }) {
+ const [open, setOpen] = useState(false)
+ const mutation = trpc.evaluation.triggerReminders.useMutation({
+ onSuccess: (data) => {
+ toast.success(`Sent ${data.sent} reminder(s)`)
+ setOpen(false)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ return (
+ <>
+ setOpen(true)}>
+
+ Send Reminders
+
+
+
+
+ Send evaluation reminders?
+
+ This will send reminder emails to all jurors who have incomplete evaluations for this round.
+
+
+
+ Cancel
+ mutation.mutate({ roundId })}
+ disabled={mutation.isPending}
+ >
+ {mutation.isPending && }
+ Send Reminders
+
+
+
+
+ >
+ )
+}
+
+// ── Export Evaluations Dialog ─────────────────────────────────────────────
+
+function ExportEvaluationsDialog({
+ roundId,
+ open,
+ onOpenChange,
+}: {
+ roundId: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}) {
+ const [exportData, setExportData] = useState(undefined)
+ const [isLoadingExport, setIsLoadingExport] = useState(false)
+ const utils = trpc.useUtils()
+
+ const handleRequestData = async () => {
+ setIsLoadingExport(true)
+ try {
+ const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true })
+ setExportData(data)
+ return data
+ } finally {
+ setIsLoadingExport(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ── Individual Assignments Table ─────────────────────────────────────────
+
+function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
+ const [addDialogOpen, setAddDialogOpen] = useState(false)
+ const [newUserId, setNewUserId] = useState('')
+ const [newProjectId, setNewProjectId] = useState('')
+
+ const utils = trpc.useUtils()
+ const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery({ roundId })
+
+ const deleteMutation = trpc.assignment.delete.useMutation({
+ onSuccess: () => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ toast.success('Assignment removed')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const createMutation = trpc.assignment.create.useMutation({
+ onSuccess: () => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ toast.success('Assignment created')
+ setAddDialogOpen(false)
+ setNewUserId('')
+ setNewProjectId('')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ return (
+
+
+
+
+ All Assignments
+
+ {assignments?.length ?? 0} individual jury-project assignments
+
+
+
setAddDialogOpen(true)}>
+
+ Add
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => )}
+
+ ) : !assignments || assignments.length === 0 ? (
+
+ No assignments yet. Generate assignments or add one manually.
+
+ ) : (
+
+
+ Juror
+ Project
+ Status
+
+
+ {assignments.map((a: any) => (
+
+ {a.user?.name || a.user?.email || 'Unknown'}
+ {a.project?.title || 'Unknown'}
+
+ {a.evaluation?.status || 'PENDING'}
+
+ deleteMutation.mutate({ id: a.id })}
+ disabled={deleteMutation.isPending}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Add Assignment Dialog */}
+
-
- {confirmAction === 'activate' && 'Activate Round'}
- {confirmAction === 'close' && 'Close Round'}
- {confirmAction === 'archive' && 'Archive Round'}
-
+ Add Assignment
- {confirmAction === 'activate' && 'This will open the round for submissions and evaluations. Projects will be able to enter this round.'}
- {confirmAction === 'close' && 'This will close the round. No more submissions or evaluations will be accepted.'}
- {confirmAction === 'archive' && 'This will archive the round. It will no longer appear in active views.'}
+ Manually assign a juror to evaluate a project
+
- setConfirmAction(null)}>Cancel
-
- {isLifecyclePending && }
- Confirm
+ setAddDialogOpen(false)}>Cancel
+ createMutation.mutate({
+ userId: newUserId,
+ projectId: newProjectId,
+ roundId,
+ })}
+ disabled={!newUserId || !newProjectId || createMutation.isPending}
+ >
+ {createMutation.isPending && }
+ Create Assignment
-
+
+ )
+}
+
+// ── Evaluation Criteria Editor ───────────────────────────────────────────
+
+function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
+ const [editing, setEditing] = useState(false)
+ const [criteria, setCriteria] = useState
>([])
+
+ const utils = trpc.useUtils()
+ const { data: form, isLoading } = trpc.evaluation.getForm.useQuery({ roundId })
+
+ const upsertMutation = trpc.evaluation.upsertForm.useMutation({
+ onSuccess: () => {
+ utils.evaluation.getForm.invalidate({ roundId })
+ toast.success('Evaluation criteria saved')
+ setEditing(false)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Sync from server
+ if (form && !editing) {
+ const serverCriteria = form.criteriaJson ?? []
+ if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) {
+ setCriteria(serverCriteria)
+ }
+ }
+
+ const handleAdd = () => {
+ setCriteria([...criteria, {
+ id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+ label: '',
+ description: '',
+ weight: 1,
+ minScore: 0,
+ maxScore: 10,
+ }])
+ setEditing(true)
+ }
+
+ const handleRemove = (id: string) => {
+ setCriteria(criteria.filter((c) => c.id !== id))
+ }
+
+ const handleChange = (id: string, field: string, value: string | number) => {
+ setCriteria(criteria.map((c) =>
+ c.id === id ? { ...c, [field]: value } : c,
+ ))
+ setEditing(true)
+ }
+
+ const handleSave = () => {
+ const validCriteria = criteria.filter((c) => c.label.trim())
+ if (validCriteria.length === 0) {
+ toast.error('Add at least one criterion')
+ return
+ }
+ upsertMutation.mutate({ roundId, criteria: validCriteria })
+ }
+
+ return (
+
+
+
+
+ Evaluation Criteria
+
+ {form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'}
+
+
+
+ {editing && (
+
{
+ setEditing(false)
+ if (form) setCriteria(form.criteriaJson)
+ }}>
+ Cancel
+
+ )}
+ {editing ? (
+
+ {upsertMutation.isPending && }
+ Save Criteria
+
+ ) : (
+
+
+ Add Criterion
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : criteria.length === 0 ? (
+
+
+
No evaluation criteria defined
+
Add criteria that jurors will use to score projects
+
+ ) : (
+
+ {criteria.map((c, idx) => (
+
+
+ {idx + 1}
+
+
+
{ handleRemove(c.id); setEditing(true) }}
+ >
+
+
+
+ ))}
+ {!editing && (
+
+
+ Add Criterion
+
+ )}
+
+ )}
+
+
)
}
diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx
index 1a37b90..0c42dd2 100644
--- a/src/components/admin/round/filtering-dashboard.tsx
+++ b/src/components/admin/round/filtering-dashboard.tsx
@@ -37,6 +37,13 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
import {
Play,
Loader2,
@@ -55,6 +62,13 @@ import {
RotateCcw,
Search,
ExternalLink,
+ Plus,
+ Pencil,
+ Trash2,
+ FileText,
+ Brain,
+ ListFilter,
+ GripVertical,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
@@ -385,6 +399,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
)}
+ {/* Filtering Rules */}
+
+
{/* Stats Cards */}
{statsLoading ? (
@@ -922,3 +939,738 @@ function ConfidenceIndicator({ value }: { value: number }) {
)
}
+
+// ─── Filtering Rules Section ────────────────────────────────────────────────
+
+type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
+
+const RULE_TYPE_META: Record
= {
+ FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
+ DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
+ AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
+}
+
+const FIELD_OPTIONS = [
+ { value: 'competitionCategory', label: 'Competition Category', operators: ['equals', 'not_equals'] },
+ { value: 'foundedAt', label: 'Founded Date', operators: ['older_than_years', 'newer_than_years'] },
+ { value: 'country', label: 'Country', operators: ['equals', 'not_equals', 'in', 'not_in'] },
+ { value: 'geographicZone', label: 'Geographic Zone', operators: ['equals', 'not_equals', 'contains'] },
+ { value: 'tags', label: 'Tags', operators: ['contains', 'in'] },
+ { value: 'oceanIssue', label: 'Ocean Issue', operators: ['equals', 'not_equals', 'in'] },
+]
+
+const FILE_TYPES = [
+ { value: 'EXEC_SUMMARY', label: 'Executive Summary' },
+ { value: 'PRESENTATION', label: 'Presentation' },
+ { value: 'BUSINESS_PLAN', label: 'Business Plan' },
+ { value: 'VIDEO', label: 'Video' },
+ { value: 'VIDEO_PITCH', label: 'Video Pitch' },
+ { value: 'SUPPORTING_DOC', label: 'Supporting Doc' },
+ { value: 'OTHER', label: 'Other' },
+]
+
+type FieldCondition = {
+ field: string
+ operator: string
+ value: string | number | string[]
+}
+
+type RuleFormData = {
+ name: string
+ ruleType: RuleType
+ priority: number
+ // FIELD_BASED
+ conditions: FieldCondition[]
+ logic: 'AND' | 'OR'
+ fieldAction: 'PASS' | 'REJECT' | 'FLAG'
+ // DOCUMENT_CHECK
+ requiredFileTypes: string[]
+ minFileCount: number | ''
+ maxPages: number | ''
+ maxPagesByFileType: Record
+ docAction: 'PASS' | 'REJECT' | 'FLAG'
+ // AI_SCREENING
+ criteriaText: string
+ aiAction: 'PASS' | 'REJECT' | 'FLAG'
+ batchSize: number
+ parallelBatches: number
+}
+
+const DEFAULT_FORM: RuleFormData = {
+ name: '',
+ ruleType: 'FIELD_BASED',
+ priority: 0,
+ conditions: [{ field: 'competitionCategory', operator: 'equals', value: '' }],
+ logic: 'AND',
+ fieldAction: 'REJECT',
+ requiredFileTypes: [],
+ minFileCount: '',
+ maxPages: '',
+ maxPagesByFileType: {},
+ docAction: 'REJECT',
+ criteriaText: '',
+ aiAction: 'FLAG',
+ batchSize: 20,
+ parallelBatches: 1,
+}
+
+function buildConfigJson(form: RuleFormData): Record {
+ switch (form.ruleType) {
+ case 'FIELD_BASED':
+ return {
+ conditions: form.conditions.map((c) => ({
+ field: c.field,
+ operator: c.operator,
+ value: c.value,
+ })),
+ logic: form.logic,
+ action: form.fieldAction,
+ }
+ case 'DOCUMENT_CHECK': {
+ const config: Record = {
+ action: form.docAction,
+ }
+ if (form.requiredFileTypes.length > 0) config.requiredFileTypes = form.requiredFileTypes
+ if (form.minFileCount !== '' && form.minFileCount > 0) config.minFileCount = form.minFileCount
+ if (form.maxPages !== '' && form.maxPages > 0) config.maxPages = form.maxPages
+ if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
+ return config
+ }
+ case 'AI_SCREENING':
+ return {
+ criteriaText: form.criteriaText,
+ action: form.aiAction,
+ batchSize: form.batchSize,
+ parallelBatches: form.parallelBatches,
+ }
+ }
+}
+
+function parseConfigToForm(rule: { name: string; ruleType: string; configJson: unknown; priority: number }): RuleFormData {
+ const config = (rule.configJson || {}) as Record
+ const base = { ...DEFAULT_FORM, name: rule.name, ruleType: rule.ruleType as RuleType, priority: rule.priority }
+
+ switch (rule.ruleType) {
+ case 'FIELD_BASED':
+ return {
+ ...base,
+ conditions: (config.conditions as FieldCondition[]) || [{ field: 'competitionCategory', operator: 'equals', value: '' }],
+ logic: (config.logic as 'AND' | 'OR') || 'AND',
+ fieldAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
+ }
+ case 'DOCUMENT_CHECK':
+ return {
+ ...base,
+ requiredFileTypes: (config.requiredFileTypes as string[]) || [],
+ minFileCount: (config.minFileCount as number) || '',
+ maxPages: (config.maxPages as number) || '',
+ maxPagesByFileType: (config.maxPagesByFileType as Record) || {},
+ docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
+ }
+ case 'AI_SCREENING':
+ return {
+ ...base,
+ criteriaText: (config.criteriaText as string) || '',
+ aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
+ batchSize: (config.batchSize as number) || 20,
+ parallelBatches: (config.parallelBatches as number) || 1,
+ }
+ default:
+ return base
+ }
+}
+
+function FilteringRulesSection({ roundId }: { roundId: string }) {
+ const [isOpen, setIsOpen] = useState(true)
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingRule, setEditingRule] = useState(null)
+ const [form, setForm] = useState({ ...DEFAULT_FORM })
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null)
+
+ const utils = trpc.useUtils()
+
+ const { data: rules, isLoading } = trpc.filtering.getRules.useQuery({ roundId })
+
+ const createMutation = trpc.filtering.createRule.useMutation({
+ onSuccess: () => {
+ utils.filtering.getRules.invalidate({ roundId })
+ setDialogOpen(false)
+ setForm({ ...DEFAULT_FORM })
+ toast.success('Rule created')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const updateMutation = trpc.filtering.updateRule.useMutation({
+ onSuccess: () => {
+ utils.filtering.getRules.invalidate({ roundId })
+ setDialogOpen(false)
+ setEditingRule(null)
+ setForm({ ...DEFAULT_FORM })
+ toast.success('Rule updated')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const deleteMutation = trpc.filtering.deleteRule.useMutation({
+ onSuccess: () => {
+ utils.filtering.getRules.invalidate({ roundId })
+ setDeleteConfirmId(null)
+ toast.success('Rule deleted')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const toggleActiveMutation = trpc.filtering.updateRule.useMutation({
+ onSuccess: () => {
+ utils.filtering.getRules.invalidate({ roundId })
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const handleSave = () => {
+ const configJson = buildConfigJson(form)
+ if (editingRule) {
+ updateMutation.mutate({ id: editingRule, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
+ } else {
+ createMutation.mutate({ roundId, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
+ }
+ }
+
+ const openEdit = (rule: any) => {
+ setEditingRule(rule.id)
+ setForm(parseConfigToForm(rule))
+ setDialogOpen(true)
+ }
+
+ const openCreate = () => {
+ setEditingRule(null)
+ setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
+ setDialogOpen(true)
+ }
+
+ const meta = RULE_TYPE_META[form.ruleType]
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Filtering Rules
+
+ {rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} — executed in priority order
+
+
+
+
+
{ e.stopPropagation(); openCreate() }}
+ >
+
+ Add Rule
+
+ {isOpen ?
:
}
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : rules && rules.length > 0 ? (
+
+ {rules.map((rule: any, idx: number) => {
+ const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
+ const Icon = typeMeta.icon
+ const config = (rule.configJson || {}) as Record
+
+ return (
+
+
+
+ {idx + 1}
+
+
+
+
+ {typeMeta.label}
+
+
+
+
{rule.name}
+
+ {rule.ruleType === 'FIELD_BASED' && (
+ <>
+ {((config.conditions as any[]) || []).length} condition{((config.conditions as any[]) || []).length !== 1 ? 's' : ''} ({config.logic as string || 'AND'}) → {config.action as string}
+ >
+ )}
+ {rule.ruleType === 'DOCUMENT_CHECK' && (
+ <>
+ {config.minFileCount ? `Min ${config.minFileCount} files` : ''}
+ {config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
+ {config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
+ {config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
+ ? ` \u00b7 Page limits per type`
+ : ''}
+ {' \u2192 '}{config.action as string}
+ >
+ )}
+ {rule.ruleType === 'AI_SCREENING' && (
+ <>
+ {((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} → {config.action as string}
+ >
+ )}
+
+
+
+
+
{
+ toggleActiveMutation.mutate({ id: rule.id, isActive: checked })
+ }}
+ />
+ openEdit(rule)}
+ >
+
+
+ setDeleteConfirmId(rule.id)}
+ >
+
+
+
+
+ )
+ })}
+
+ ) : (
+
+
+
No filtering rules configured
+
+ Add rules to define how projects are screened
+
+
+
+ Add First Rule
+
+
+ )}
+
+
+
+
+
+ {/* Create/Edit Rule Dialog */}
+ {
+ setDialogOpen(open)
+ if (!open) { setEditingRule(null); setForm({ ...DEFAULT_FORM }) }
+ }}>
+
+
+ {editingRule ? 'Edit Rule' : 'Create Filtering Rule'}
+
+ {editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'}
+
+
+
+
+ {/* Rule Name + Priority */}
+
+
+ {/* Rule Type Selector */}
+
+
Rule Type
+
+ {(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
+ const Icon = m.icon
+ const selected = form.ruleType === type
+ return (
+
setForm((f) => ({ ...f, ruleType: type }))}
+ >
+
+
+ {m.label}
+
+ {m.description}
+
+ )
+ })}
+
+
+
+ {/* Type-Specific Config */}
+ {form.ruleType === 'FIELD_BASED' && (
+
+
+
Conditions
+
+ setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
+
+
+
+
+ AND
+ OR
+
+
+
+
+
+ {form.conditions.map((cond, i) => {
+ const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
+ return (
+
+
+ {i === 0 && Field }
+ {
+ const newConds = [...form.conditions]
+ const newFieldMeta = FIELD_OPTIONS.find((f) => f.value === v)
+ newConds[i] = { field: v, operator: newFieldMeta?.operators[0] || 'equals', value: '' }
+ setForm((f) => ({ ...f, conditions: newConds }))
+ }}
+ >
+
+
+
+
+ {FIELD_OPTIONS.map((fo) => (
+ {fo.label}
+ ))}
+
+
+
+
+ {i === 0 && Operator }
+ {
+ const newConds = [...form.conditions]
+ newConds[i] = { ...newConds[i], operator: v }
+ setForm((f) => ({ ...f, conditions: newConds }))
+ }}
+ >
+
+
+
+
+ {(fieldMeta?.operators || ['equals']).map((op) => (
+ {op.replace(/_/g, ' ')}
+ ))}
+
+
+
+
+ {i === 0 && Value }
+ {
+ const newConds = [...form.conditions]
+ const val = ['in', 'not_in'].includes(cond.operator)
+ ? e.target.value.split(',').map((s) => s.trim())
+ : ['older_than_years', 'newer_than_years'].includes(cond.operator)
+ ? parseInt(e.target.value) || 0
+ : e.target.value
+ newConds[i] = { ...newConds[i], value: val }
+ setForm((f) => ({ ...f, conditions: newConds }))
+ }}
+ />
+
+
{
+ const newConds = form.conditions.filter((_, j) => j !== i)
+ setForm((f) => ({ ...f, conditions: newConds }))
+ }}
+ >
+
+
+
+ )
+ })}
+
+
setForm((f) => ({
+ ...f,
+ conditions: [...f.conditions, { field: 'competitionCategory', operator: 'equals', value: '' }],
+ }))}
+ >
+
+ Add Condition
+
+
+
+ Action when conditions match
+ setForm((f) => ({ ...f, fieldAction: v as any }))}>
+
+
+
+
+ Pass (keep project)
+ Reject (filter out)
+ Flag (manual review)
+
+
+
+
+ )}
+
+ {form.ruleType === 'DOCUMENT_CHECK' && (
+
+
+
+
+
Required File Formats
+
+ {['pdf', 'docx', 'pptx', 'mp4', 'xlsx'].map((ext) => (
+
+ {
+ setForm((f) => ({
+ ...f,
+ requiredFileTypes: checked
+ ? [...f.requiredFileTypes, ext]
+ : f.requiredFileTypes.filter((t) => t !== ext),
+ }))
+ }}
+ />
+ .{ext}
+
+ ))}
+
+
+
+
+
Max Pages by File Type
+
+ {FILE_TYPES.map((ft) => {
+ const limit = form.maxPagesByFileType[ft.value]
+ const hasLimit = limit !== undefined
+ return (
+
+ {
+ setForm((f) => {
+ const next = { ...f.maxPagesByFileType }
+ if (checked) next[ft.value] = 10
+ else delete next[ft.value]
+ return { ...f, maxPagesByFileType: next }
+ })
+ }}
+ />
+ {ft.label}
+ {hasLimit && (
+ {
+ setForm((f) => ({
+ ...f,
+ maxPagesByFileType: { ...f.maxPagesByFileType, [ft.value]: parseInt(e.target.value) || 1 },
+ }))
+ }}
+ />
+ )}
+ {hasLimit && pages max }
+
+ )
+ })}
+
+
+
+
+ Action when check fails
+ setForm((f) => ({ ...f, docAction: v as any }))}>
+
+
+
+
+ Reject (filter out)
+ Flag (manual review)
+ Pass (informational)
+
+
+
+
+ )}
+
+ {form.ruleType === 'AI_SCREENING' && (
+
+
+
+
+
+ Action
+ setForm((f) => ({ ...f, aiAction: v as any }))}>
+
+
+
+
+ Flag for review
+ Auto-reject
+ Auto-pass
+
+
+
+
+ Batch Size
+ setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
+ />
+
+
+ Parallel Batches
+ setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
+ />
+
+
+
+ )}
+
+
+
+ { setDialogOpen(false); setEditingRule(null); setForm({ ...DEFAULT_FORM }) }}>
+ Cancel
+
+
+ {(createMutation.isPending || updateMutation.isPending) && }
+ {editingRule ? 'Update Rule' : 'Create Rule'}
+
+
+
+
+
+ {/* Delete Confirmation */}
+ { if (!open) setDeleteConfirmId(null) }}>
+
+
+ Delete Filtering Rule?
+
+ This will permanently remove this rule. Projects already filtered will not be affected.
+
+
+
+ Cancel
+ {
+ if (deleteConfirmId) deleteMutation.mutate({ id: deleteConfirmId })
+ }}
+ >
+ {deleteMutation.isPending && }
+ Delete
+
+
+
+
+ >
+ )
+}
diff --git a/src/lib/minio.ts b/src/lib/minio.ts
index dc2fa1c..6518f7f 100644
--- a/src/lib/minio.ts
+++ b/src/lib/minio.ts
@@ -119,14 +119,39 @@ export async function deleteObject(
}
/**
- * Generate a unique object key for a project file
+ * Sanitize a name for use as a MinIO path segment.
+ * Removes special characters, replaces spaces with underscores, limits length.
*/
-export function generateObjectKey(
- projectId: string,
- fileName: string
-): string {
- const timestamp = Date.now()
- const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
- return `projects/${projectId}/${timestamp}-${sanitizedName}`
+function sanitizePath(name: string): string {
+ return (
+ name
+ .trim()
+ .replace(/[^a-zA-Z0-9\-_ ]/g, '')
+ .replace(/\s+/g, '_')
+ .substring(0, 100) || 'unnamed'
+ )
+}
+
+/**
+ * Generate a unique object key for a project file.
+ *
+ * Structure: {ProjectName}/{RoundName}/{timestamp}-{fileName}
+ * - projectName: human-readable project title (sanitized)
+ * - roundName: round name for submission context (sanitized), defaults to "general"
+ * - fileName: original file name (sanitized)
+ *
+ * Existing files with old-style keys (projects/{id}/...) are unaffected
+ * because retrieval uses the objectKey stored in the database.
+ */
+export function generateObjectKey(
+ projectName: string,
+ fileName: string,
+ roundName?: string
+): string {
+ const timestamp = Date.now()
+ const sanitizedProject = sanitizePath(projectName)
+ const sanitizedRound = roundName ? sanitizePath(roundName) : 'general'
+ const sanitizedFile = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
+ return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}`
}
diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts
index d8a4fc8..0cca9fd 100644
--- a/src/server/routers/applicant.ts
+++ b/src/server/routers/applicant.ts
@@ -2,7 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
-import { getPresignedUrl } from '@/lib/minio'
+import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
@@ -306,9 +306,17 @@ export const applicantRouter = router({
})
}
- const timestamp = Date.now()
- const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
- const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
+ // Fetch round name for storage path (if uploading against a round)
+ let roundName: string | undefined
+ if (input.roundId) {
+ const round = await ctx.prisma.round.findUnique({
+ where: { id: input.roundId },
+ select: { name: true },
+ })
+ roundName = round?.name
+ }
+
+ const objectKey = generateObjectKey(project.title, input.fileName, roundName)
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts
index 265225e..320d7bd 100644
--- a/src/server/routers/assignment.ts
+++ b/src/server/routers/assignment.ts
@@ -241,7 +241,7 @@ export const assignmentRouter = router({
.query(async ({ ctx, input }) => {
const where: Record = {
userId: ctx.user.id,
- round: { status: 'STAGE_ACTIVE' },
+ round: { status: 'ROUND_ACTIVE' },
}
if (input.roundId) {
diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts
index 8af289f..c866a2b 100644
--- a/src/server/routers/evaluation.ts
+++ b/src/server/routers/evaluation.ts
@@ -1019,6 +1019,129 @@ export const evaluationRouter = router({
return discussion
}),
+ // =========================================================================
+ // Evaluation Form CRUD (Admin)
+ // =========================================================================
+
+ /**
+ * Get active evaluation form for a round (admin view with full details)
+ */
+ getForm: adminProcedure
+ .input(z.object({ roundId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const form = await ctx.prisma.evaluationForm.findFirst({
+ where: { roundId: input.roundId, isActive: true },
+ })
+
+ if (!form) return null
+
+ return {
+ id: form.id,
+ roundId: form.roundId,
+ version: form.version,
+ isActive: form.isActive,
+ criteriaJson: form.criteriaJson as Array<{
+ id: string
+ label: string
+ description?: string
+ weight?: number
+ minScore?: number
+ maxScore?: number
+ }>,
+ scalesJson: form.scalesJson as Record | null,
+ createdAt: form.createdAt,
+ updatedAt: form.updatedAt,
+ }
+ }),
+
+ /**
+ * Create or update the evaluation form for a round.
+ * Deactivates any existing active form and creates a new versioned one.
+ */
+ upsertForm: adminProcedure
+ .input(
+ z.object({
+ roundId: z.string(),
+ criteria: z.array(
+ z.object({
+ id: z.string(),
+ label: z.string().min(1).max(255),
+ description: z.string().max(2000).optional(),
+ weight: z.number().min(0).max(100).optional(),
+ minScore: z.number().int().min(0).optional(),
+ maxScore: z.number().int().min(1).optional(),
+ })
+ ).min(1),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { roundId, criteria } = input
+
+ // Verify round exists
+ await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
+
+ // Get current max version for this round
+ const latestForm = await ctx.prisma.evaluationForm.findFirst({
+ where: { roundId },
+ orderBy: { version: 'desc' },
+ select: { version: true },
+ })
+ const nextVersion = (latestForm?.version ?? 0) + 1
+
+ // Build criteriaJson with defaults
+ const criteriaJson = criteria.map((c) => ({
+ id: c.id,
+ label: c.label,
+ description: c.description || '',
+ weight: c.weight ?? 1,
+ scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`,
+ required: true,
+ }))
+
+ // Auto-generate scalesJson from criteria min/max ranges
+ const scaleSet = new Set(criteriaJson.map((c) => c.scale))
+ const scalesJson: Record = {}
+ for (const scale of scaleSet) {
+ const [min, max] = scale.split('-').map(Number)
+ scalesJson[scale] = { min, max }
+ }
+
+ // Transaction: deactivate old → create new
+ const form = await ctx.prisma.$transaction(async (tx) => {
+ await tx.evaluationForm.updateMany({
+ where: { roundId, isActive: true },
+ data: { isActive: false },
+ })
+
+ return tx.evaluationForm.create({
+ data: {
+ roundId,
+ version: nextVersion,
+ criteriaJson,
+ scalesJson,
+ isActive: true,
+ },
+ })
+ })
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'UPSERT_EVALUATION_FORM',
+ entityType: 'EvaluationForm',
+ entityId: form.id,
+ detailsJson: {
+ roundId,
+ version: nextVersion,
+ criteriaCount: criteria.length,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return form
+ }),
+
// =========================================================================
// Phase 4: Stage-scoped evaluation procedures
// =========================================================================
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index dc10969..937c598 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -149,20 +149,27 @@ export const fileRouter = router({
})
}
- let isLate = false
- if (input.roundId) {
- const stage = await ctx.prisma.round.findUnique({
- where: { id: input.roundId },
- select: { windowCloseAt: true },
- })
+ // Fetch project title and optional round name for storage path
+ const [project, roundInfo] = await Promise.all([
+ ctx.prisma.project.findUniqueOrThrow({
+ where: { id: input.projectId },
+ select: { title: true },
+ }),
+ input.roundId
+ ? ctx.prisma.round.findUnique({
+ where: { id: input.roundId },
+ select: { name: true, windowCloseAt: true },
+ })
+ : null,
+ ])
- if (stage?.windowCloseAt) {
- isLate = new Date() > stage.windowCloseAt
- }
+ let isLate = false
+ if (roundInfo?.windowCloseAt) {
+ isLate = new Date() > roundInfo.windowCloseAt
}
const bucket = BUCKET_NAME
- const objectKey = generateObjectKey(input.projectId, input.fileName)
+ const objectKey = generateObjectKey(project.title, input.fileName, roundInfo?.name)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
@@ -1122,8 +1129,20 @@ export const fileRouter = router({
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
fileType = 'PRESENTATION'
+ // Fetch project title and window name for storage path
+ const [project, submissionWindow] = await Promise.all([
+ ctx.prisma.project.findUniqueOrThrow({
+ where: { id: input.projectId },
+ select: { title: true },
+ }),
+ ctx.prisma.submissionWindow.findUniqueOrThrow({
+ where: { id: input.submissionWindowId },
+ select: { name: true },
+ }),
+ ])
+
const bucket = BUCKET_NAME
- const objectKey = generateObjectKey(input.projectId, input.fileName)
+ const objectKey = generateObjectKey(project.title, input.fileName, submissionWindow.name)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
// Remove any existing file for this project+requirement combo (replace)
diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts
index 66adc3d..21b9b6b 100644
--- a/src/server/routers/filtering.ts
+++ b/src/server/routers/filtering.ts
@@ -54,7 +54,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
project: {
include: {
files: {
- select: { id: true, fileName: true, fileType: true },
+ select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
},
},
},
@@ -513,7 +513,7 @@ export const filteringRouter = router({
project: {
include: {
files: {
- select: { id: true, fileName: true, fileType: true },
+ select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
},
},
},
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts
index 8a93eab..65c11cb 100644
--- a/src/server/routers/round.ts
+++ b/src/server/routers/round.ts
@@ -227,6 +227,137 @@ export const roundRouter = router({
return existing
}),
+ // =========================================================================
+ // Project Advancement (Manual Only)
+ // =========================================================================
+
+ /**
+ * Advance PASSED projects from one round to the next.
+ * This is ALWAYS manual — no auto-advancement after AI filtering.
+ * Admin must explicitly trigger this after reviewing results.
+ */
+ advanceProjects: adminProcedure
+ .input(
+ z.object({
+ roundId: z.string(),
+ targetRoundId: z.string().optional(),
+ projectIds: z.array(z.string()).optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { roundId, targetRoundId, projectIds } = input
+
+ // Get current round with competition context
+ const currentRound = await ctx.prisma.round.findUniqueOrThrow({
+ where: { id: roundId },
+ select: { id: true, name: true, competitionId: true, sortOrder: true },
+ })
+
+ // Determine target round
+ let targetRound: { id: string; name: string }
+ if (targetRoundId) {
+ targetRound = await ctx.prisma.round.findUniqueOrThrow({
+ where: { id: targetRoundId },
+ select: { id: true, name: true },
+ })
+ } else {
+ // Find next round in same competition by sortOrder
+ const nextRound = await ctx.prisma.round.findFirst({
+ where: {
+ competitionId: currentRound.competitionId,
+ sortOrder: { gt: currentRound.sortOrder },
+ },
+ orderBy: { sortOrder: 'asc' },
+ select: { id: true, name: true },
+ })
+ if (!nextRound) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'No subsequent round exists in this competition. Create the next round first.',
+ })
+ }
+ targetRound = nextRound
+ }
+
+ // Determine which projects to advance
+ let idsToAdvance: string[]
+ if (projectIds && projectIds.length > 0) {
+ idsToAdvance = projectIds
+ } else {
+ // Default: all PASSED projects in current round
+ const passedStates = await ctx.prisma.projectRoundState.findMany({
+ where: { roundId, state: 'PASSED' },
+ select: { projectId: true },
+ })
+ idsToAdvance = passedStates.map((s) => s.projectId)
+ }
+
+ if (idsToAdvance.length === 0) {
+ return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
+ }
+
+ // Transaction: create entries in target round + mark current as COMPLETED
+ await ctx.prisma.$transaction(async (tx) => {
+ // Create ProjectRoundState in target round
+ await tx.projectRoundState.createMany({
+ data: idsToAdvance.map((projectId) => ({
+ projectId,
+ roundId: targetRound.id,
+ })),
+ skipDuplicates: true,
+ })
+
+ // Mark current round states as COMPLETED
+ await tx.projectRoundState.updateMany({
+ where: {
+ roundId,
+ projectId: { in: idsToAdvance },
+ state: 'PASSED',
+ },
+ data: { state: 'COMPLETED' },
+ })
+
+ // Update project status to ASSIGNED
+ await tx.project.updateMany({
+ where: { id: { in: idsToAdvance } },
+ data: { status: 'ASSIGNED' },
+ })
+
+ // Status history
+ await tx.projectStatusHistory.createMany({
+ data: idsToAdvance.map((projectId) => ({
+ projectId,
+ status: 'ASSIGNED',
+ changedBy: ctx.user?.id,
+ })),
+ })
+ })
+
+ // Audit
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'ADVANCE_PROJECTS',
+ entityType: 'Round',
+ entityId: roundId,
+ detailsJson: {
+ fromRound: currentRound.name,
+ toRound: targetRound.name,
+ targetRoundId: targetRound.id,
+ projectCount: idsToAdvance.length,
+ projectIds: idsToAdvance,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return {
+ advancedCount: idsToAdvance.length,
+ targetRoundId: targetRound.id,
+ targetRoundName: targetRound.name,
+ }
+ }),
+
// =========================================================================
// Submission Window Management
// =========================================================================
diff --git a/src/server/services/ai-filtering.ts b/src/server/services/ai-filtering.ts
index 747a6e0..eb244e4 100644
--- a/src/server/services/ai-filtering.ts
+++ b/src/server/services/ai-filtering.ts
@@ -67,6 +67,8 @@ export type FieldRuleConfig = {
export type DocumentCheckConfig = {
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
minFileCount?: number
+ maxPages?: number // Max pages for ANY file
+ maxPagesByFileType?: Record // e.g. { "EXECUTIVE_SUMMARY": 2, "PITCH_DECK": 10 }
action: 'PASS' | 'REJECT' | 'FLAG'
}
@@ -110,7 +112,7 @@ interface ProjectForFiltering {
institution?: string | null
submissionSource?: SubmissionSource
submittedAt?: Date | null
- files: Array<{ id: string; fileName: string; fileType?: FileType | null }>
+ files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
_count?: {
teamMembers?: number
files?: number
@@ -162,10 +164,22 @@ Return a JSON object with this exact structure:
- 3-4: Weak — significant shortcomings against criteria
- 1-2: Poor — does not meet criteria or appears low-quality/spam
+## Available Data Per Project
+- category: STARTUP or BUSINESS_CONCEPT
+- country, region: geographic location (use for regional considerations)
+- founded_year: when the company/initiative was founded (use for age checks)
+- ocean_issue: the ocean conservation area
+- file_count, file_types: uploaded documents summary
+- files[]: per-file details with file_type, page_count (if known), and size_kb
+- description: project summary text
+- tags: topic tags
+
## Guidelines
- Evaluate ONLY against the provided criteria, not your own standards
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
- Flag spam_risk=true for: AI-generated filler text, copied content, or irrelevant submissions
+- When criteria differ by category (e.g. stricter for STARTUP vs BUSINESS_CONCEPT), apply the appropriate threshold
+- When criteria mention regional considerations (e.g. African projects), use the country/region fields
- Do not include any personal identifiers in reasoning
- If project data is insufficient to evaluate, set confidence below 0.3`
@@ -293,6 +307,25 @@ export function evaluateDocumentRule(
}
}
+ // Check global max pages (any file exceeding limit fails)
+ if (config.maxPages !== undefined) {
+ const overLimit = files.some((f) => f.pageCount != null && f.pageCount > config.maxPages!)
+ if (overLimit) {
+ return { passed: false, action: config.action }
+ }
+ }
+
+ // Check per-fileType max pages (e.g. EXECUTIVE_SUMMARY: 2, PITCH_DECK: 10)
+ if (config.maxPagesByFileType && Object.keys(config.maxPagesByFileType).length > 0) {
+ for (const file of files) {
+ if (!file.fileType || file.pageCount == null) continue
+ const limit = config.maxPagesByFileType[file.fileType]
+ if (limit !== undefined && file.pageCount > limit) {
+ return { passed: false, action: config.action }
+ }
+ }
+ }
+
return { passed: true, action: config.action }
}
diff --git a/src/server/services/anonymization.ts b/src/server/services/anonymization.ts
index 4b70a25..9dff94b 100644
--- a/src/server/services/anonymization.ts
+++ b/src/server/services/anonymization.ts
@@ -79,6 +79,12 @@ export interface AnonymizationResult {
* Comprehensive anonymized project data for AI filtering
* Includes all fields needed for flexible filtering criteria
*/
+export interface AnonymizedFileInfo {
+ file_type: string // FileType enum value
+ page_count: number | null // Number of pages if known
+ size_kb: number // File size in KB
+}
+
export interface AnonymizedProjectForAI {
project_id: string // P1, P2, etc.
title: string // Sanitized
@@ -94,6 +100,7 @@ export interface AnonymizedProjectForAI {
has_description: boolean
file_count: number
file_types: string[] // FileType values
+ files: AnonymizedFileInfo[] // Per-file details for document analysis
wants_mentorship: boolean
submission_source: SubmissionSource
submitted_date: string | null // YYYY-MM-DD only
@@ -121,7 +128,7 @@ export interface ProjectWithRelations {
teamMembers?: number
files?: number
}
- files?: Array<{ fileType: FileType | null }>
+ files?: Array<{ fileType: FileType | null; size?: number; pageCount?: number | null }>
}
/**
@@ -153,7 +160,7 @@ export function toProjectWithRelations(project: {
submissionSource?: string
submittedAt?: Date | null
_count?: { teamMembers?: number; files?: number }
- files?: Array<{ fileType?: string | null; [key: string]: unknown }>
+ files?: Array<{ fileType?: string | null; size?: number; pageCount?: number | null; [key: string]: unknown }>
}): ProjectWithRelations {
return {
id: project.id,
@@ -173,7 +180,11 @@ export function toProjectWithRelations(project: {
teamMembers: project._count?.teamMembers ?? 0,
files: project._count?.files ?? project.files?.length ?? 0,
},
- files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
+ files: project.files?.map((f) => ({
+ fileType: (f.fileType as FileType) ?? null,
+ size: f.size,
+ pageCount: f.pageCount ?? null,
+ })) ?? [],
}
}
@@ -288,6 +299,11 @@ export function anonymizeProjectForAI(
file_types: project.files
?.map((f) => f.fileType)
.filter((ft): ft is FileType => ft !== null) ?? [],
+ files: project.files?.map((f) => ({
+ file_type: f.fileType ?? 'OTHER',
+ page_count: f.pageCount ?? null,
+ size_kb: Math.round((f.size ?? 0) / 1024),
+ })) ?? [],
wants_mentorship: project.wantsMentorship ?? false,
submission_source: project.submissionSource,
submitted_date: project.submittedAt?.toISOString().split('T')[0] ?? null,