From 875c2e8f48297523d0e8ce003400d1d95d58070a Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 20:18:50 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20security=20hardening=20=E2=80=94=20block?= =?UTF-8?q?=20self-registration,=20SSE=20auth,=20audit=20logging=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Block self-registration via magic link (PrismaAdapter createUser throws) - Magic links only sent to existing ACTIVE users (prevents enumeration) - signIn callback rejects non-existent users (defense-in-depth) - Change schema default role from JURY_MEMBER to APPLICANT - Add authentication to live-voting SSE stream endpoint - Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load (remove purpose from eagerly pre-fetched URL queries) Bug fixes: - Fix impersonation skeleton screen on applicant dashboard - Fix onboarding redirect loop in auth layout Observer dashboard redesign (Steps 1-6): - Clickable round pipeline with selected round highlighting - Round-type-specific dashboard panels (intake, filtering, evaluation, submission, mentoring, live final, deliberation) - Enhanced activity feed with server-side humanization - Previous round comparison section - New backend queries for round-specific analytics Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 2 +- src/app/(admin)/admin/awards/[id]/page.tsx | 2 +- .../(applicant)/applicant/documents/page.tsx | 4 +- src/app/(applicant)/applicant/page.tsx | 2 +- src/app/(auth)/layout.tsx | 29 +- src/app/api/live-voting/stream/route.ts | 10 + .../observer/dashboard/deliberation-panel.tsx | 22 + .../observer/dashboard/evaluation-panel.tsx | 261 ++++++++ .../observer/dashboard/filtering-panel.tsx | 251 ++++++++ .../observer/dashboard/intake-panel.tsx | 163 +++++ .../observer/dashboard/live-final-panel.tsx | 164 ++++++ .../observer/dashboard/mentoring-panel.tsx | 139 +++++ .../dashboard/previous-round-section.tsx | 148 +++++ .../observer/dashboard/submission-panel.tsx | 164 ++++++ .../observer/observer-dashboard-content.tsx | 507 +++++----------- .../observer/observer-edition-context.tsx | 52 +- .../shared/requirement-upload-slot.tsx | 4 +- src/lib/auth.ts | 33 +- src/server/routers/analytics.ts | 557 +++++++++++++++++- src/server/routers/logo.ts | 6 +- src/server/routers/program.ts | 1 + src/server/routers/specialAward.ts | 12 +- src/types/competition-configs.ts | 3 + 23 files changed, 2126 insertions(+), 410 deletions(-) create mode 100644 src/components/observer/dashboard/deliberation-panel.tsx create mode 100644 src/components/observer/dashboard/evaluation-panel.tsx create mode 100644 src/components/observer/dashboard/filtering-panel.tsx create mode 100644 src/components/observer/dashboard/intake-panel.tsx create mode 100644 src/components/observer/dashboard/live-final-panel.tsx create mode 100644 src/components/observer/dashboard/mentoring-panel.tsx create mode 100644 src/components/observer/dashboard/previous-round-section.tsx create mode 100644 src/components/observer/dashboard/submission-panel.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d788d28..b79fc7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -302,7 +302,7 @@ model User { email String @unique name String? emailVerified DateTime? // Required by NextAuth Prisma adapter - role UserRole @default(JURY_MEMBER) + role UserRole @default(APPLICANT) roles UserRole[] @default([]) status UserStatus @default(INVITED) expertiseTags String[] @default([]) diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index 65b8dfe..8c4e9cd 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -1337,7 +1337,7 @@ export default function AwardDetailPage({
- +

{j.user.name || 'Unnamed'} diff --git a/src/app/(applicant)/applicant/documents/page.tsx b/src/app/(applicant)/applicant/documents/page.tsx index b1a7880..eed3343 100644 --- a/src/app/(applicant)/applicant/documents/page.tsx +++ b/src/app/(applicant)/applicant/documents/page.tsx @@ -46,11 +46,11 @@ const fileTypeLabels: Record = { function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { const { data: viewData } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: false, purpose: 'open' as const }, + { bucket, objectKey, forDownload: false }, { staleTime: 10 * 60 * 1000 } ) const { data: dlData } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const }, + { bucket, objectKey, forDownload: true, fileName }, { staleTime: 10 * 60 * 1000 } ) const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 847193a..654e2b0 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -62,7 +62,7 @@ export default function ApplicantDashboardPage() { enabled: isAuthenticated, }) - if (sessionStatus === 'loading' || isLoading) { + if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) { return (

diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 11eb169..f4daf9e 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -26,18 +26,23 @@ export default async function AuthLayout({ }) if (dbUser) { - - const role = session.user.role - if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') { - redirect('/admin') - } else if (role === 'JURY_MEMBER') { - redirect('/jury') - } else if (role === 'OBSERVER') { - redirect('/observer') - } else if (role === 'MENTOR') { - redirect('/mentor') - } else if (role === 'APPLICANT') { - redirect('/applicant') + // If user hasn't completed onboarding, don't redirect away from auth pages. + // The /onboarding page lives in this (auth) layout, so they need to stay here. + if (!dbUser.onboardingCompletedAt) { + // Fall through — let them access /onboarding (and other auth pages) + } else { + const role = session.user.role + if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') { + redirect('/admin') + } else if (role === 'JURY_MEMBER') { + redirect('/jury') + } else if (role === 'OBSERVER') { + redirect('/observer') + } else if (role === 'MENTOR') { + redirect('/mentor') + } else if (role === 'APPLICANT') { + redirect('/applicant') + } } } // If user doesn't exist in DB, fall through and show auth page diff --git a/src/app/api/live-voting/stream/route.ts b/src/app/api/live-voting/stream/route.ts index ccefe99..b67ddf1 100644 --- a/src/app/api/live-voting/stream/route.ts +++ b/src/app/api/live-voting/stream/route.ts @@ -1,9 +1,19 @@ import { NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' export const dynamic = 'force-dynamic' export async function GET(request: NextRequest): Promise { + // Require authentication — prevent unauthenticated access to live vote data + const userSession = await auth() + if (!userSession?.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + const { searchParams } = new URL(request.url) const sessionId = searchParams.get('sessionId') diff --git a/src/components/observer/dashboard/deliberation-panel.tsx b/src/components/observer/dashboard/deliberation-panel.tsx new file mode 100644 index 0000000..eaa66eb --- /dev/null +++ b/src/components/observer/dashboard/deliberation-panel.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Lock } from 'lucide-react' + +export function DeliberationPanel() { + return ( +
+ + +
+ +
+

Results Coming Soon

+

+ The jury is deliberating. Results will be shared when finalized. +

+
+
+
+ ) +} diff --git a/src/components/observer/dashboard/evaluation-panel.tsx b/src/components/observer/dashboard/evaluation-panel.tsx new file mode 100644 index 0000000..906dfea --- /dev/null +++ b/src/components/observer/dashboard/evaluation-panel.tsx @@ -0,0 +1,261 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { StatusBadge } from '@/components/shared/status-badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { + Target, + TrendingUp, + Users, + ChevronDown, + ChevronUp, + Award, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +export function EvaluationPanel({ roundId, programId }: { roundId: string; programId: string }) { + const [expandedJurorId, setExpandedJurorId] = useState(null) + + const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: advConfig } = trpc.analytics.getRoundAdvancementConfig.useQuery( + { roundId }, + { refetchInterval: 60_000 }, + ) + + const { data: dashStats } = trpc.analytics.getDashboardStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery( + { programId, roundId }, + { enabled: !!programId, refetchInterval: 30_000 }, + ) + + const { data: projectsData } = trpc.analytics.getAllProjects.useQuery( + { roundId, perPage: 8 }, + { refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + totalAssignments: number + completedEvaluations: number + completionRate: number + activeJurors: number + } | undefined + + const allJurors = jurorWorkload ?? [] + const projects = projectsData?.projects ?? [] + + const scoreDistribution = dashStats?.scoreDistribution ?? [] + const maxScoreCount = Math.max(...scoreDistribution.map((b) => b.count), 1) + const scoreColors: Record = { + '9-10': '#053d57', + '7-8': '#1e7a8a', + '5-6': '#557f8c', + '3-4': '#c4453a', + '1-2': '#de0f1e', + } + + return ( +
+ {/* Advancement Method Card */} + {advConfig && ( + + + +
+
+ +
+
+

+ {advConfig.advanceMode === 'threshold' + ? `Score Threshold ≥ ${advConfig.advanceScoreThreshold ?? '?'}` + : 'Top N Advancement'} +

+ {advConfig.advanceMode === 'count' && ( +

+ {advConfig.startupAdvanceCount != null && `${advConfig.startupAdvanceCount} Startups`} + {advConfig.startupAdvanceCount != null && advConfig.conceptAdvanceCount != null && ', '} + {advConfig.conceptAdvanceCount != null && `${advConfig.conceptAdvanceCount} Business Concepts`} +

+ )} +
+
+
+
+
+ )} + + {/* Completion Progress */} + {statsLoading ? ( + + ) : stats ? ( + +
+ Evaluation Progress + + {stats.completionRate}% + +
+ +

+ {stats.completedEvaluations} / {stats.totalAssignments} evaluations · {stats.activeJurors} jurors +

+
+ ) : null} + + {/* Score Distribution */} + {scoreDistribution.length > 0 && ( + + + + + + Score Distribution + + + +
+ {scoreDistribution.map((bucket) => ( +
+ + {bucket.label} + +
+
0 ? (bucket.count / maxScoreCount) * 100 : 0}%`, + backgroundColor: scoreColors[bucket.label] ?? '#557f8c', + }} + /> +
+ + {bucket.count} + +
+ ))} +
+ + + + )} + + {/* Juror Workload */} + + + + + + Juror Workload + + + + {allJurors.length > 0 ? ( +
+ {allJurors.map((juror) => { + const isExpanded = expandedJurorId === juror.id + return ( +
+ + {isExpanded && juror.projects && ( +
+ {juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => ( + + {proj.title} + + + ))} +
+ )} +
+ ) + })} +
+ ) : ( +

No juror assignments yet.

+ )} +
+
+
+ + {/* Recently Reviewed */} + {projects.length > 0 && ( + + + + + + Recently Reviewed + + + +
+ {projects + .filter((p) => { + const s = p.observerStatus ?? p.status + return s !== 'NOT_REVIEWED' && s !== 'SUBMITTED' + }) + .slice(0, 6) + .map((p) => ( + + {p.title} +
+ + + {p.evaluationCount > 0 && p.averageScore !== null + ? p.averageScore.toFixed(1) + : '—'} + +
+ + ))} +
+
+
+
+ )} +
+ ) +} diff --git a/src/components/observer/dashboard/filtering-panel.tsx b/src/components/observer/dashboard/filtering-panel.tsx new file mode 100644 index 0000000..ccfd922 --- /dev/null +++ b/src/components/observer/dashboard/filtering-panel.tsx @@ -0,0 +1,251 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '@/components/ui/button' +import { AnimatedCard } from '@/components/shared/animated-container' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' + +export function FilteringPanel({ roundId }: { roundId: string }) { + const [outcomeFilter, setOutcomeFilter] = useState('ALL') + const [page, setPage] = useState(1) + const [expandedId, setExpandedId] = useState(null) + + const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: resultStats } = trpc.analytics.getFilteringResultStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: results, isLoading: resultsLoading } = trpc.analytics.getFilteringResults.useQuery( + { + roundId, + outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED', + page, + perPage: 15, + }, + { refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + totalScreened: number + passed: number + filteredOut: number + flagged: number + passRate: number + } | undefined + + const total = stats?.totalScreened ?? 0 + + const outcomeColor: Record = { + PASSED: 'bg-emerald-500', + FILTERED_OUT: 'bg-rose-500', + FLAGGED: 'bg-amber-500', + } + + return ( +
+ {/* Screening Stats Bar */} + {statsLoading ? ( + + ) : stats ? ( + +
+ + Screening Results + {total} screened +
+ {/* Segmented bar */} + {total > 0 && ( +
+
+
+
+
+ )} +
+ + + Passed {stats.passed} + + + + Filtered {stats.filteredOut} + + + + Flagged {stats.flagged} + +
+ + ) : null} + + {/* Detailed Stats */} + {resultStats && ( +
+ +

+ {resultStats.passed} +

+

Passed

+
+ +

+ {resultStats.overridden} +

+

Overridden

+
+ +

+ {resultStats.total > 0 ? Math.round((resultStats.passed / resultStats.total) * 100) : 0}% +

+

Pass Rate

+
+
+ )} + + {/* AI Results Table */} + + + +
+ AI Screening Results + +
+
+ + {resultsLoading ? ( +
+ {[...Array(5)].map((_, i) => )} +
+ ) : results && results.results.length > 0 ? ( + <> +
+ {results.results.map((r: any) => ( +
+ + {expandedId === r.id && ( +
+
+ {(() => { + const screening = r.aiScreeningJson as Record | null + const reasoning = (screening?.reasoning ?? screening?.explanation ?? r.overrideReason ?? 'No details available') as string + return reasoning + })()} +
+
+ )} +
+ ))} +
+ {/* Pagination */} + {results.totalPages > 1 && ( +
+ + Page {results.page} of {results.totalPages} + +
+ + +
+
+ )} + + ) : ( +
No screening results yet.
+ )} +
+
+
+
+ ) +} diff --git a/src/components/observer/dashboard/intake-panel.tsx b/src/components/observer/dashboard/intake-panel.tsx new file mode 100644 index 0000000..395acc1 --- /dev/null +++ b/src/components/observer/dashboard/intake-panel.tsx @@ -0,0 +1,163 @@ +'use client' + +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { Inbox, Globe, FolderOpen } from 'lucide-react' + +function relativeTime(date: Date | string): string { + const now = Date.now() + const then = new Date(date).getTime() + const diff = Math.floor((now - then) / 1000) + if (diff < 60) return `${diff}s ago` + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + +export function IntakePanel({ roundId, programId }: { roundId: string; programId: string }) { + const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: projectsData } = trpc.analytics.getAllProjects.useQuery( + { roundId, perPage: 8 }, + { refetchInterval: 30_000 }, + ) + + const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery( + { programId, roundId }, + { enabled: !!programId, refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + totalProjects: number + byCategory: { category: string; count: number }[] + byState: { state: string; count: number }[] + } | undefined + + const projects = projectsData?.projects ?? [] + const topCountries = (geoData ?? []).slice(0, 10) + + return ( +
+ {/* Stats Cards */} + {statsLoading ? ( +
+ {[...Array(3)].map((_, i) => )} +
+ ) : stats ? ( +
+ +

{stats.totalProjects}

+

Total Projects

+
+ {stats.byCategory.map((c) => ( + +

{c.count}

+

{c.category}

+
+ ))} +
+ ) : null} + + {/* Recent Submissions */} + + + + + + Recent Submissions + + + + {projects.length > 0 ? ( +
+ {projects.map((p) => ( + +
+

{p.title}

+

+ {p.teamName ?? 'No team'} · {p.country ?? ''} +

+
+ + {p.country ?? ''} + + + ))} +
+ ) : ( +
No submissions yet.
+ )} +
+
+
+ + {/* Country Ranking */} + {topCountries.length > 0 && ( + + + + + + Top Countries + + + +
+ {topCountries.map((c, i) => ( +
+ + {i + 1}. + {c.countryCode} + + + {c.count} + +
+ ))} +
+
+
+
+ )} + + {/* Category Breakdown */} + {stats && stats.byCategory.length > 0 && ( + + + + + + Category Breakdown + + + +
+ {stats.byCategory.map((c) => { + const pct = stats.totalProjects > 0 ? Math.round((c.count / stats.totalProjects) * 100) : 0 + return ( +
+

{pct}%

+

{c.category}

+
+ ) + })} +
+
+
+
+ )} +
+ ) +} diff --git a/src/components/observer/dashboard/live-final-panel.tsx b/src/components/observer/dashboard/live-final-panel.tsx new file mode 100644 index 0000000..ec63d44 --- /dev/null +++ b/src/components/observer/dashboard/live-final-panel.tsx @@ -0,0 +1,164 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react' +import { cn } from '@/lib/utils' + +const SESSION_STATUS_CONFIG: Record = { + NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' }, + IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true }, + PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' }, + COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' }, +} + +export function LiveFinalPanel({ roundId }: { roundId: string }) { + const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery( + { roundId }, + { refetchInterval: 10_000 }, + ) + + const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + sessionStatus: string + voteCount: number + } | undefined + + const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED' + const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED + + const jurors = liveDash?.jurors ?? [] + const votedCount = jurors.filter((j: any) => j.hasVoted).length + const standings = liveDash?.standings ?? [] + const visibility = liveDash?.observerScoreVisibility ?? 'after_completion' + const scoresVisible = visibility === 'realtime' + || (visibility === 'after_completion' && sessionStatus === 'COMPLETED') + + return ( +
+ {/* Session Status Card */} + {isLoading ? ( + + ) : ( + +
+
+ + {statusConfig.pulse && ( + + )} +
+
+

+ {statusConfig.label} +

+

+ {liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast +

+
+
+
+ )} + + {/* Vote Count */} +
+ +

+ {liveDash?.voteCount ?? stats?.voteCount ?? 0} +

+

Total Votes

+
+ +

+ {votedCount}/{jurors.length} +

+

Jurors Voted

+
+
+ + {/* Juror Participation */} + {jurors.length > 0 && ( + + + + + + Juror Participation + + + +
+ {jurors.map((j: any) => ( +
+ {j.name} + + {j.hasVoted ? 'Voted' : 'Pending'} + +
+ ))} +
+
+
+
+ )} + + {/* Standings / Score Visibility */} + + + + + + Standings + {scoresVisible ? ( + + ) : ( + + )} + + + + {scoresVisible && standings.length > 0 ? ( +
+ {standings.map((s: any, i: number) => ( +
+
+ + {i + 1}. + + {s.projectTitle} +
+ + {typeof s.score === 'number' ? s.score.toFixed(1) : s.score} + +
+ ))} +
+ ) : ( +
+ +

+ {sessionStatus === 'COMPLETED' + ? 'Scores are hidden by admin configuration.' + : 'Scores will be revealed when voting completes.'} +

+
+ )} +
+
+
+
+ ) +} diff --git a/src/components/observer/dashboard/mentoring-panel.tsx b/src/components/observer/dashboard/mentoring-panel.tsx new file mode 100644 index 0000000..819aa65 --- /dev/null +++ b/src/components/observer/dashboard/mentoring-panel.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { MessageCircle, Users, ChevronDown, ChevronUp } from 'lucide-react' + +export function MentoringPanel({ roundId }: { roundId: string }) { + const [expandedMentorId, setExpandedMentorId] = useState(null) + + const { data: mentoringData, isLoading } = trpc.analytics.getMentoringDashboard.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + mentorAssignments: number + totalMessages: number + } | undefined + + const assignments = mentoringData?.assignments ?? [] + const activeMentors = mentoringData?.activeMentors ?? 0 + const totalMentors = mentoringData?.totalMentors ?? 0 + + return ( +
+ {/* Stats Cards */} + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => )} +
+ ) : ( +
+ +

+ {activeMentors}/{totalMentors} +

+

Active Mentors

+
+ +

+ {mentoringData?.totalMessages ?? stats?.totalMessages ?? 0} +

+

Messages

+
+ +

+ {stats?.mentorAssignments ?? assignments.length} +

+

Assignments

+
+
+ )} + + {/* Mentor-Mentee Pairings */} + + + + + + Mentor-Mentee Pairings + + + + {assignments.length > 0 ? ( +
+ {assignments.map((mentor: any) => { + const isExpanded = expandedMentorId === mentor.mentorId + return ( +
+ + {isExpanded && mentor.projects && ( +
+ {mentor.projects.map((proj: any) => ( + +
+

{proj.title}

+

+ {proj.teamName ?? ''} +

+
+
+ + {proj.messageCount} +
+ + ))} +
+ )} +
+ ) + })} +
+ ) : ( +

No mentor assignments yet.

+ )} +
+
+
+
+ ) +} diff --git a/src/components/observer/dashboard/previous-round-section.tsx b/src/components/observer/dashboard/previous-round-section.tsx new file mode 100644 index 0000000..8e22a7b --- /dev/null +++ b/src/components/observer/dashboard/previous-round-section.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { AnimatedCard } from '@/components/shared/animated-container' +import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react' +import { cn } from '@/lib/utils' + +export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) { + const [collapsed, setCollapsed] = useState(false) + + const { data, isLoading } = trpc.analytics.getPreviousRoundComparison.useQuery( + { currentRoundId }, + { refetchInterval: 60_000 }, + ) + + if (isLoading) { + return + } + + if (!data || !data.hasPrevious) { + return null + } + + const { previousRound, currentRound, eliminated, categoryBreakdown, countryAttrition } = data + + return ( + + + + + + + {!collapsed && ( + + {/* Headline Stat */} +
+ +
+

+ {eliminated} project{eliminated !== 1 ? 's' : ''} eliminated +

+

+ {previousRound.projectCount} → {currentRound.projectCount} +

+
+
+ + {/* Category Survival Bars */} + {categoryBreakdown && categoryBreakdown.length > 0 && ( +
+

+ By Category +

+ {categoryBreakdown.map((cat: any) => { + const maxVal = Math.max(cat.previous, 1) + const prevPct = 100 + const currPct = (cat.current / maxVal) * 100 + return ( +
+
+ {cat.category} + + {cat.previous} → {cat.current} + (-{cat.eliminated}) + +
+
+
+
+
+
+ ) + })} +
+ )} + + {/* Country Attrition */} + {countryAttrition && countryAttrition.length > 0 && ( +
+

+ Country Attrition (Top 10) +

+
+ {countryAttrition.map((c: any) => ( +
+ {c.country} + + -{c.lost} + +
+ ))} +
+
+ )} + + {/* Score Comparison */} + {previousRound.avgScore != null && currentRound.avgScore != null && ( +
+ +

{previousRound.name}

+

+ {typeof previousRound.avgScore === 'number' + ? previousRound.avgScore.toFixed(1) + : previousRound.avgScore} +

+

Avg Score

+
+ +

{currentRound.name}

+

+ {typeof currentRound.avgScore === 'number' + ? currentRound.avgScore.toFixed(1) + : currentRound.avgScore} +

+

Avg Score

+
+
+ )} + + )} + + + ) +} diff --git a/src/components/observer/dashboard/submission-panel.tsx b/src/components/observer/dashboard/submission-panel.tsx new file mode 100644 index 0000000..d998be9 --- /dev/null +++ b/src/components/observer/dashboard/submission-panel.tsx @@ -0,0 +1,164 @@ +'use client' + +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { FileText, Upload, Users } from 'lucide-react' + +function relativeTime(date: Date | string): string { + const now = Date.now() + const then = new Date(date).getTime() + const diff = Math.floor((now - then) / 1000) + if (diff < 60) return `${diff}s ago` + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + +const FILE_TYPE_ICONS: Record = { + pdf: '📄', + image: '🖼️', + video: '🎥', + default: '📎', +} + +function fileIcon(fileType: string | null | undefined): string { + if (!fileType) return FILE_TYPE_ICONS.default + const ft = fileType.toLowerCase() + if (ft.includes('pdf')) return FILE_TYPE_ICONS.pdf + if (ft.includes('image') || ft.includes('png') || ft.includes('jpg') || ft.includes('jpeg')) return FILE_TYPE_ICONS.image + if (ft.includes('video') || ft.includes('mp4')) return FILE_TYPE_ICONS.video + return FILE_TYPE_ICONS.default +} + +export function SubmissionPanel({ roundId, programId }: { roundId: string; programId: string }) { + const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + const { data: recentFiles } = trpc.analytics.getRecentFiles.useQuery( + { roundId, limit: 10 }, + { refetchInterval: 30_000 }, + ) + + const { data: projectsData } = trpc.analytics.getAllProjects.useQuery( + { roundId, perPage: 15 }, + { refetchInterval: 30_000 }, + ) + + const stats = roundStats?.stats as { + totalFiles: number + teamsSubmitted: number + } | undefined + + const files = recentFiles ?? [] + const projects = projectsData?.projects ?? [] + + return ( +
+ {/* Stats Cards */} + {statsLoading ? ( +
+ + +
+ ) : stats ? ( +
+ +
+ +

{stats.totalFiles}

+
+

Files Uploaded

+
+ +
+ +

{stats.teamsSubmitted}

+
+

Teams Submitted

+
+
+ ) : null} + + {/* Recent Document Uploads */} + {files.length > 0 && ( + + + + + + Recent Documents + + + +
+ {files.map((f: any) => ( +
+ + {fileIcon(f.fileType)} + +
+

{f.fileName}

+

+ + {f.project?.title ?? 'Unknown project'} + +

+
+ + {f.createdAt ? relativeTime(f.createdAt) : ''} + +
+ ))} +
+
+
+
+ )} + + {/* Project Teams */} + {projects.length > 0 && ( + + + + + + Project Teams + + + +
+ {projects.map((p) => ( + +
+

{p.title}

+

+ {p.teamName ?? 'No team'} · {p.country ?? ''} +

+
+ + {p.country ?? '—'} + + + ))} +
+
+
+
+ )} +
+ ) +} diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index 7f99e68..fa2a501 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -1,8 +1,5 @@ 'use client' -import { useState } from 'react' -import Link from 'next/link' -import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' import { Card, @@ -14,36 +11,30 @@ import { import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { StatusBadge } from '@/components/shared/status-badge' import { AnimatedCard } from '@/components/shared/animated-container' import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card' import { useEditionContext } from '@/components/observer/observer-edition-context' import { - ClipboardList, BarChart3, - TrendingUp, - Users, Globe, - ChevronRight, Activity, - ChevronDown, - ChevronUp, - ArrowRight, - Lock, Clock, CheckCircle, - XCircle, + ClipboardList, + Upload, + Users, } from 'lucide-react' import { cn } from '@/lib/utils' +import { IntakePanel } from '@/components/observer/dashboard/intake-panel' +import { FilteringPanel } from '@/components/observer/dashboard/filtering-panel' +import { EvaluationPanel } from '@/components/observer/dashboard/evaluation-panel' +import { SubmissionPanel } from '@/components/observer/dashboard/submission-panel' +import { MentoringPanel } from '@/components/observer/dashboard/mentoring-panel' +import { LiveFinalPanel } from '@/components/observer/dashboard/live-final-panel' +import { DeliberationPanel } from '@/components/observer/dashboard/deliberation-panel' +import { PreviousRoundSection } from '@/components/observer/dashboard/previous-round-section' + function relativeTime(date: Date | string): string { const now = Date.now() const then = new Date(date).getTime() @@ -56,11 +47,7 @@ function relativeTime(date: Date | string): string { function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string { const midpoints: Record = { - '9-10': 9.5, - '7-8': 7.5, - '5-6': 5.5, - '3-4': 3.5, - '1-2': 1.5, + '9-10': 9.5, '7-8': 7.5, '5-6': 5.5, '3-4': 3.5, '1-2': 1.5, } let total = 0 let weightedSum = 0 @@ -75,52 +62,6 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]): return (weightedSum / total).toFixed(1) } -const ACTIVITY_ICONS: Record = { - ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' }, - ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' }, - 'round.reopened': { icon: Clock, color: 'text-emerald-500' }, - 'round.closed': { icon: Lock, color: 'text-slate-500' }, - EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' }, - ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' }, - PROJECT_ADVANCED: { icon: ArrowRight, color: 'text-teal-500' }, - PROJECT_REJECTED: { icon: XCircle, color: 'text-rose-500' }, - RESULT_LOCKED: { icon: Lock, color: 'text-amber-500' }, -} - -function humanizeActivity(item: { eventType: string; actorName?: string | null; details?: Record | null }): string { - const actor = item.actorName ?? 'System' - const details = item.details ?? {} - const projectName = (details.projectTitle ?? details.projectName ?? '') as string - const roundName = (details.roundName ?? '') as string - - switch (item.eventType) { - case 'EVALUATION_SUBMITTED': - return projectName - ? `${actor} submitted a review for ${projectName}` - : `${actor} submitted a review` - case 'ROUND_ACTIVATED': - case 'round.reopened': - return roundName ? `${roundName} was opened` : 'A round was opened' - case 'ROUND_CLOSED': - case 'round.closed': - return roundName ? `${roundName} was closed` : 'A round was closed' - case 'ASSIGNMENT_CREATED': - return projectName - ? `${projectName} was assigned to a juror` - : 'A project was assigned' - case 'PROJECT_ADVANCED': - return projectName - ? `${projectName} advanced${roundName ? ` to ${roundName}` : ''}` - : 'A project advanced' - case 'PROJECT_REJECTED': - return projectName ? `${projectName} was rejected` : 'A project was rejected' - case 'RESULT_LOCKED': - return roundName ? `Results locked for ${roundName}` : 'Results were locked' - default: - return `${actor}: ${item.eventType.replace(/_/g, ' ').toLowerCase()}` - } -} - const STATUS_BADGE_VARIANT: Record = { ROUND_ACTIVE: 'default', ROUND_CLOSED: 'secondary', @@ -128,11 +69,52 @@ const STATUS_BADGE_VARIANT: Record ROUND_ARCHIVED: 'secondary', } -export function ObserverDashboardContent({ userName }: { userName?: string }) { - const { programs, selectedProgramId, activeRoundId } = useEditionContext() - const [expandedJurorId, setExpandedJurorId] = useState(null) +const CATEGORY_ICONS: Record = { + round: { icon: Clock, color: 'text-teal-500' }, + evaluation: { icon: CheckCircle, color: 'text-blue-500' }, + project: { icon: ClipboardList, color: 'text-emerald-500' }, + file: { icon: Upload, color: 'text-violet-500' }, + deliberation: { icon: Users, color: 'text-amber-500' }, + system: { icon: Activity, color: 'text-slate-400' }, +} - const roundIdParam = activeRoundId || undefined +function RoundPanel({ roundType, roundId, programId }: { roundType: string; roundId: string; programId: string }) { + switch (roundType) { + case 'INTAKE': + return + case 'FILTERING': + return + case 'EVALUATION': + return + case 'SUBMISSION': + return + case 'MENTORING': + return + case 'LIVE_FINAL': + return + case 'DELIBERATION': + return + default: + return ( + +

Select a round to view details.

+
+ ) + } +} + +export function ObserverDashboardContent({ userName }: { userName?: string }) { + const { + programs, + selectedProgramId, + selectedRoundId, + setSelectedRoundId, + selectedRoundType, + rounds, + activeRoundId, + } = useEditionContext() + + const roundIdParam = selectedRoundId || undefined const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery( { roundId: roundIdParam }, @@ -147,51 +129,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { { enabled: !!competitionId, refetchInterval: 30_000 }, ) - const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery( - { programId: selectedProgramId || undefined }, - { enabled: !!selectedProgramId, refetchInterval: 30_000 }, - ) - const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery( { programId: selectedProgramId }, { enabled: !!selectedProgramId, refetchInterval: 30_000 }, ) - const { data: projectsData } = trpc.analytics.getAllProjects.useQuery( - { perPage: 10 }, - { refetchInterval: 30_000 }, - ) - const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery( - { limit: 10 }, + { limit: 15, roundId: selectedRoundId || undefined }, { refetchInterval: 30_000 }, ) const countryCount = geoData ? geoData.length : 0 - const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—' - const allJurors = jurorWorkload ?? [] - - const scoreColors: Record = { - '9-10': '#053d57', - '7-8': '#1e7a8a', - '5-6': '#557f8c', - '3-4': '#c4453a', - '1-2': '#de0f1e', - } - - const maxScoreCount = stats - ? Math.max(...stats.scoreDistribution.map((b) => b.count), 1) - : 1 - - const recentlyReviewed = (projectsData?.projects ?? []).filter( - (p) => { - const status = p.observerStatus ?? p.status - return status !== 'REJECTED' && status !== 'NOT_REVIEWED' && status !== 'SUBMITTED' - }, - ) - return (
{/* Header */} @@ -227,7 +177,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { ) : null} - {/* Pipeline */} + {/* Clickable Pipeline */} @@ -237,59 +187,80 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
Competition Pipeline - Round-by-round progression overview + Click a round to view its details {overviewLoading || !competitionId ? (
{[...Array(4)].map((_, i) => ( - + ))}
) : roundOverview && roundOverview.rounds.length > 0 ? (
- {roundOverview.rounds.map((round, idx) => ( -
- - -

- {round.roundName} -

-
- - {round.roundType.replace(/_/g, ' ')} - - - {round.roundStatus === 'ROUND_ACTIVE' - ? 'Active' - : round.roundStatus === 'ROUND_CLOSED' - ? 'Closed' - : round.roundStatus === 'ROUND_DRAFT' - ? 'Draft' - : round.roundStatus === 'ROUND_ARCHIVED' - ? 'Archived' - : round.roundStatus} - -
-

- {round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''} -

-
- -

- {round.completionRate}% complete -

-
-
-
- {idx < roundOverview.rounds.length - 1 && ( -
- )} -
- ))} + {roundOverview.rounds.map((round, idx) => { + const isSelected = selectedRoundId === round.roundId + const isActive = round.roundStatus === 'ROUND_ACTIVE' + return ( +
+ + {idx < roundOverview.rounds.length - 1 && ( +
+ )} +
+ ) + })}
) : (

No round data available for this competition.

@@ -298,202 +269,26 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { - {/* Middle Row */} + {/* Main Content: Round Panel + Activity Feed */}
- {/* Left column: Score Distribution + Recently Reviewed stacked */} -
- {/* Score Distribution */} - - - - -
- -
- Score Distribution -
-
- - {stats ? ( -
- {stats.scoreDistribution.map((bucket) => ( -
- - {bucket.label} - -
-
0 ? (bucket.count / maxScoreCount) * 100 : 0}%`, - backgroundColor: scoreColors[bucket.label] ?? '#557f8c', - }} - /> -
- - {bucket.count} - -
- ))} -
- ) : ( -
- {[...Array(5)].map((_, i) => ( - - ))} -
- )} - + {/* Left: Round-specific panel */} +
+ {selectedRoundId && selectedRoundType ? ( + + ) : ( + +

Select a round from the pipeline above.

- - - {/* Recently Reviewed */} - - - - -
- -
- Recently Reviewed -
- Latest project reviews -
- - {recentlyReviewed.length > 0 ? ( - <> - - - - Project - Status - Score - - - - {recentlyReviewed.map((project) => ( - - - - {project.title} - - - - - - - {project.evaluationCount > 0 && project.averageScore !== null - ? project.averageScore.toFixed(1) - : '—'} - - - ))} - -
-
- - View All - -
- - ) : ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- )} -
-
-
+ )}
- {/* Juror Workload — scrollable list of all jurors */} - - - - -
- -
- Juror Workload -
- All jurors by assignment -
- - {allJurors.length > 0 ? ( -
- {allJurors.map((juror) => { - const isExpanded = expandedJurorId === juror.id - return ( -
- - {isExpanded && juror.projects && ( -
- {juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => ( - - {proj.title} - - - ))} -
- )} -
- ) - })} -
- ) : ( -
- {[...Array(5)].map((_, i) => ( -
- - -
- ))} -
- )} -
-
-
- - {/* Activity Feed */} + {/* Right: Activity Feed */} - +
@@ -503,21 +298,18 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { Recent platform events - + {activityFeed && activityFeed.length > 0 ? ( -
- {activityFeed - .filter((item) => !item.eventType.includes('transitioned') && !item.eventType.includes('transition')) - .slice(0, 5) - .map((item) => { - const iconDef = ACTIVITY_ICONS[item.eventType] - const IconComponent = iconDef?.icon ?? Activity - const iconColor = iconDef?.color ?? 'text-slate-400' +
+ {activityFeed.slice(0, 8).map((item) => { + const iconDef = CATEGORY_ICONS[item.category ?? 'system'] ?? CATEGORY_ICONS.system + const IconComponent = iconDef.icon + const iconColor = iconDef.color return (

- {humanizeActivity(item)} + {item.description}

{relativeTime(item.createdAt)} @@ -542,6 +334,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
+ {/* Previous Round Comparison */} + {selectedRoundId && ( + + )} + {/* Full-width Map */} {selectedProgramId ? ( diff --git a/src/components/observer/observer-edition-context.tsx b/src/components/observer/observer-edition-context.tsx index e73b382..55389fa 100644 --- a/src/components/observer/observer-edition-context.tsx +++ b/src/components/observer/observer-edition-context.tsx @@ -1,13 +1,22 @@ 'use client' -import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +import { createContext, useContext, useState, useEffect, useMemo, type ReactNode } from 'react' import { trpc } from '@/lib/trpc/client' +type RoundInfo = { + id: string + name: string + status: string + competitionId?: string + roundType?: string + sortOrder?: number +} + type Program = { id: string name: string | null year?: number - rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }> + rounds?: RoundInfo[] } type EditionContextValue = { @@ -15,6 +24,13 @@ type EditionContextValue = { selectedProgramId: string setSelectedProgramId: (id: string) => void activeRoundId: string + /** The user-selected round (defaults to best/active round) */ + selectedRoundId: string + setSelectedRoundId: (id: string) => void + /** Derived roundType for the selected round */ + selectedRoundType: string + /** All rounds for the selected program (sorted by sortOrder) */ + rounds: RoundInfo[] } const EditionContext = createContext(null) @@ -35,23 +51,37 @@ function findBestRound(rounds: Array<{ id: string; status: string }>): string { export function EditionProvider({ children }: { children: ReactNode }) { const [selectedProgramId, setSelectedProgramId] = useState('') + const [selectedRoundId, setSelectedRoundId] = useState('') const { data: programs } = trpc.program.list.useQuery( { includeStages: true }, { refetchInterval: 30_000 }, ) - useEffect(() => { - if (programs && programs.length > 0 && !selectedProgramId) { - setSelectedProgramId(programs[0].id) - } - }, [programs, selectedProgramId]) - const typedPrograms = (programs ?? []) as Program[] const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId) - const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }> + const rounds = useMemo( + () => ((selectedProgram?.rounds ?? []) as RoundInfo[]).slice().sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)), + [selectedProgram?.rounds], + ) const activeRoundId = findBestRound(rounds) + // Auto-select first program + useEffect(() => { + if (typedPrograms.length > 0 && !selectedProgramId) { + setSelectedProgramId(typedPrograms[0].id) + } + }, [typedPrograms, selectedProgramId]) + + // Auto-select best round when program changes or rounds load + useEffect(() => { + if (rounds.length > 0 && (!selectedRoundId || !rounds.some(r => r.id === selectedRoundId))) { + setSelectedRoundId(findBestRound(rounds)) + } + }, [rounds, selectedRoundId]) + + const selectedRoundType = rounds.find(r => r.id === selectedRoundId)?.roundType ?? '' + return ( {children} diff --git a/src/components/shared/requirement-upload-slot.tsx b/src/components/shared/requirement-upload-slot.tsx index 291f618..ce7c5de 100644 --- a/src/components/shared/requirement-upload-slot.tsx +++ b/src/components/shared/requirement-upload-slot.tsx @@ -72,7 +72,7 @@ interface RequirementUploadSlotProps { function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: false, purpose: 'open' as const }, + { bucket, objectKey, forDownload: false }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url @@ -87,7 +87,7 @@ function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: stri function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const }, + { bucket, objectKey, forDownload: true, fileName }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 89470b2..aaedba3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -27,6 +27,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, adapter: { ...PrismaAdapter(prisma), + // Block auto-creation of users via magic link — only pre-created users can log in + createUser: () => { + throw new Error('Self-registration is not allowed. Please contact an administrator.') + }, async useVerificationToken({ identifier, token }: { identifier: string; token: string }) { try { return await prisma.verificationToken.delete({ @@ -39,7 +43,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, providers: [ - // Email provider for magic links (used for first login and password reset) + // Email provider for magic links (only for existing active users) EmailProvider({ // Server config required by NextAuth validation but not used — // sendVerificationRequest below fully overrides email sending via getTransporter() @@ -54,6 +58,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ from: process.env.EMAIL_FROM || 'MOPC Platform ', maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes sendVerificationRequest: async ({ identifier: email, url }) => { + // Only send magic links to existing, ACTIVE users + const existingUser = await prisma.user.findUnique({ + where: { email: email.toLowerCase().trim() }, + select: { status: true }, + }) + if (!existingUser || existingUser.status !== 'ACTIVE') { + // Silently skip — don't reveal whether the email exists (prevents enumeration) + console.log(`[auth] Magic link requested for non-active/unknown email: ${email}`) + return + } await sendMagicLinkEmail(email, url) }, }), @@ -355,7 +369,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }) - if (dbUser?.status === 'SUSPENDED') { + // Block non-existent users (defense-in-depth against adapter auto-creation) + if (!dbUser) { + return false + } + + if (dbUser.status === 'SUSPENDED') { return false // Block suspended users } @@ -363,12 +382,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // The completeOnboarding mutation sets status to ACTIVE. // Add user data for JWT callback - if (dbUser) { - user.id = dbUser.id - user.role = dbUser.role - user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role] - user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash - } + user.id = dbUser.id + user.role = dbUser.role + user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role] + user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash } // Update last login time on actual sign-in diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 9ffb5ee..98325cf 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -4,6 +4,8 @@ import { normalizeCountryToCode } from '@/lib/countries' import { getUserAvatarUrl } from '../utils/avatar-url' import { getProjectLogoUrl } from '../utils/project-logo-url' import { aggregateVotes } from '../services/deliberation' +import { validateRoundConfig } from '@/types/competition-configs' +import type { LiveFinalConfig } from '@/types/competition-configs' const editionOrRoundInput = z.object({ roundId: z.string().optional(), @@ -1482,13 +1484,26 @@ export const analyticsRouter = router({ * Activity feed — recent audit log entries for observer dashboard */ getActivityFeed: observerProcedure - .input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional()) + .input(z.object({ + limit: z.number().min(1).max(50).default(10), + roundId: z.string().optional(), + }).optional()) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 10 + const roundId = input?.roundId - const entries = await ctx.prisma.decisionAuditLog.findMany({ + // --- DecisionAuditLog entries (dot-notation events) --- + const dalWhere: Record = {} + if (roundId) { + dalWhere.OR = [ + { entityType: 'Round', entityId: roundId }, + { detailsJson: { path: ['roundId'], equals: roundId } }, + ] + } + const dalEntries = await ctx.prisma.decisionAuditLog.findMany({ + where: dalWhere, orderBy: { createdAt: 'desc' }, - take: limit, + take: limit * 2, select: { id: true, eventType: true, @@ -1500,25 +1515,203 @@ export const analyticsRouter = router({ }, }) + // --- AuditLog entries (SCREAMING_SNAKE_CASE actions) --- + const alActions = [ + 'EVALUATION_SUBMITTED', 'EVALUATION_SAVE_DRAFT', + 'PROJECT_CREATE', 'PROJECT_UPDATE', + 'FILE_VIEWED', 'FILE_OPENED', 'FILE_DOWNLOADED', + ] + const alWhere: Record = { action: { in: alActions } } + if (roundId) { + alWhere.detailsJson = { path: ['roundId'], equals: roundId } + } + const alEntries = await ctx.prisma.auditLog.findMany({ + where: alWhere, + orderBy: { timestamp: 'desc' }, + take: limit * 2, + select: { + id: true, + action: true, + entityType: true, + entityId: true, + userId: true, + detailsJson: true, + timestamp: true, + }, + }) + // Batch-fetch actor names - const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[] - const actors = actorIds.length > 0 + const allActorIds = [ + ...new Set([ + ...dalEntries.map((e) => e.actorId), + ...alEntries.map((e) => e.userId), + ].filter(Boolean)), + ] as string[] + const actors = allActorIds.length > 0 ? await ctx.prisma.user.findMany({ - where: { id: { in: actorIds } }, + where: { id: { in: allActorIds } }, select: { id: true, name: true }, }) : [] const actorMap = new Map(actors.map((a) => [a.id, a.name])) - return entries.map((entry) => ({ - id: entry.id, - eventType: entry.eventType, - entityType: entry.entityType, - entityId: entry.entityId, - actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null, - details: entry.detailsJson as Record | null, - createdAt: entry.createdAt, - })) + type FeedItem = { + id: string + description: string + category: 'round' | 'evaluation' | 'project' | 'file' | 'deliberation' | 'system' + createdAt: Date + } + + // Humanize DecisionAuditLog entries + const dalItems: FeedItem[] = dalEntries + .filter((e) => !e.eventType.includes('transitioned') && !e.eventType.includes('cursor_updated')) + .map((entry) => { + const actor = entry.actorId ? actorMap.get(entry.actorId) ?? 'System' : 'System' + const details = (entry.detailsJson ?? {}) as Record + const roundName = (details.roundName ?? '') as string + const projectTitle = (details.projectTitle ?? details.projectName ?? '') as string + + let description: string + let category: FeedItem['category'] = 'system' + + switch (entry.eventType) { + case 'round.activated': + case 'round.reopened': + description = roundName ? `${roundName} was opened` : 'A round was opened' + category = 'round' + break + case 'round.closed': + description = roundName ? `${roundName} was closed` : 'A round was closed' + category = 'round' + break + case 'round.archived': + description = roundName ? `${roundName} was archived` : 'A round was archived' + category = 'round' + break + case 'round.finalized': + description = roundName ? `Results finalized for ${roundName}` : 'Round results finalized' + category = 'round' + break + case 'results.locked': + description = roundName ? `Results locked for ${roundName}` : 'Results were locked' + category = 'round' + break + case 'results.unlocked': + description = roundName ? `Results unlocked for ${roundName}` : 'Results were unlocked' + category = 'round' + break + case 'override.applied': + description = projectTitle + ? `${actor} overrode decision for ${projectTitle}` + : `${actor} applied a decision override` + category = 'project' + break + case 'finalization.project_outcome': + description = projectTitle + ? `${projectTitle} outcome: ${(details.outcome as string) ?? 'determined'}` + : 'Project outcome determined' + category = 'project' + break + case 'deliberation.created': + description = 'Deliberation session created' + category = 'deliberation' + break + case 'deliberation.finalized': + description = 'Deliberation session finalized' + category = 'deliberation' + break + case 'deliberation.admin_override': + description = `${actor} applied deliberation override` + category = 'deliberation' + break + case 'live.session_started': + description = 'Live voting session started' + category = 'round' + break + case 'submission_window.opened': + description = 'Submission window opened' + category = 'round' + break + case 'submission_window.closed': + description = 'Submission window closed' + category = 'round' + break + case 'mentor_workspace.activated': + description = projectTitle + ? `Mentoring workspace activated for ${projectTitle}` + : 'Mentoring workspace activated' + category = 'project' + break + default: + description = `${actor}: ${entry.eventType.replace(/[_.]/g, ' ')}` + break + } + + return { id: entry.id, description, category, createdAt: entry.createdAt } + }) + + // Humanize AuditLog entries + const alItems: FeedItem[] = alEntries.map((entry) => { + const actor = entry.userId ? actorMap.get(entry.userId) ?? 'Someone' : 'System' + const details = (entry.detailsJson ?? {}) as Record + const projectTitle = (details.projectTitle ?? details.entityLabel ?? '') as string + + let description: string + let category: FeedItem['category'] = 'system' + + switch (entry.action) { + case 'EVALUATION_SUBMITTED': + description = projectTitle + ? `${actor} submitted evaluation for ${projectTitle}` + : `${actor} submitted an evaluation` + category = 'evaluation' + break + case 'EVALUATION_SAVE_DRAFT': + description = projectTitle + ? `${actor} saved draft evaluation for ${projectTitle}` + : `${actor} saved a draft evaluation` + category = 'evaluation' + break + case 'PROJECT_CREATE': + description = projectTitle + ? `New project submitted: ${projectTitle}` + : 'New project submitted' + category = 'project' + break + case 'PROJECT_UPDATE': + description = projectTitle + ? `${projectTitle} was updated` + : 'A project was updated' + category = 'project' + break + case 'FILE_VIEWED': + case 'FILE_OPENED': + description = `${actor} viewed a document${projectTitle ? ` for ${projectTitle}` : ''}` + category = 'file' + break + case 'FILE_DOWNLOADED': + description = `${actor} downloaded a document${projectTitle ? ` for ${projectTitle}` : ''}` + category = 'file' + break + default: + description = `${actor}: ${entry.action.replace(/_/g, ' ').toLowerCase()}` + break + } + + return { + id: `al_${entry.id}`, + description, + category, + createdAt: entry.timestamp, + } + }) + + // Merge and sort by date, take limit + const merged = [...dalItems, ...alItems] + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, limit) + + return merged }), // ========================================================================= @@ -1752,4 +1945,338 @@ export const analyticsRouter = router({ totalProjects: projectAssignCounts.size, } }), + + // ========================================================================= + // Observer Dashboard V2 Queries + // ========================================================================= + + getPreviousRoundComparison: observerProcedure + .input(z.object({ currentRoundId: z.string() })) + .query(async ({ ctx, input }) => { + const currentRound = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.currentRoundId }, + select: { id: true, name: true, roundType: true, sortOrder: true, competitionId: true }, + }) + + // Find the previous round by sortOrder + const previousRound = await ctx.prisma.round.findFirst({ + where: { + competitionId: currentRound.competitionId, + sortOrder: { lt: currentRound.sortOrder }, + }, + orderBy: { sortOrder: 'desc' }, + select: { id: true, name: true, roundType: true, sortOrder: true }, + }) + + if (!previousRound) { + return { hasPrevious: false as const } + } + + // Get project counts and category breakdowns for both rounds + const [prevStates, currStates, prevProjects, currProjects] = await Promise.all([ + ctx.prisma.projectRoundState.findMany({ + where: { roundId: previousRound.id }, + select: { projectId: true, state: true }, + }), + ctx.prisma.projectRoundState.findMany({ + where: { roundId: currentRound.id }, + select: { projectId: true, state: true }, + }), + ctx.prisma.project.findMany({ + where: { projectRoundStates: { some: { roundId: previousRound.id } } }, + select: { id: true, competitionCategory: true, country: true }, + }), + ctx.prisma.project.findMany({ + where: { projectRoundStates: { some: { roundId: currentRound.id } } }, + select: { id: true, competitionCategory: true, country: true }, + }), + ]) + + const prevPassedCount = prevStates.filter(s => s.state === 'PASSED').length + const prevRejectedCount = prevStates.filter(s => s.state === 'REJECTED').length + + // Category breakdown + const prevByCategory = new Map() + prevProjects.forEach(p => { + const cat = p.competitionCategory ?? 'Uncategorized' + prevByCategory.set(cat, (prevByCategory.get(cat) ?? 0) + 1) + }) + const currByCategory = new Map() + currProjects.forEach(p => { + const cat = p.competitionCategory ?? 'Uncategorized' + currByCategory.set(cat, (currByCategory.get(cat) ?? 0) + 1) + }) + const allCategories = new Set([...prevByCategory.keys(), ...currByCategory.keys()]) + const categoryBreakdown = [...allCategories].map(cat => ({ + category: cat, + previous: prevByCategory.get(cat) ?? 0, + current: currByCategory.get(cat) ?? 0, + eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0), + })) + + // Country attrition + const prevByCountry = new Map() + prevProjects.forEach(p => { + const c = p.country ?? 'Unknown' + prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1) + }) + const currByCountry = new Map() + currProjects.forEach(p => { + const c = p.country ?? 'Unknown' + currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1) + }) + const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()]) + const countryAttrition = [...allCountries] + .map(country => ({ + country, + previous: prevByCountry.get(country) ?? 0, + current: currByCountry.get(country) ?? 0, + lost: (prevByCountry.get(country) ?? 0) - (currByCountry.get(country) ?? 0), + })) + .filter(c => c.lost > 0) + .sort((a, b) => b.lost - a.lost) + .slice(0, 10) + + // Average scores (if evaluation rounds) + let prevAvgScore: number | null = null + let currAvgScore: number | null = null + const [prevEvals, currEvals] = await Promise.all([ + ctx.prisma.evaluation.findMany({ + where: { assignment: { roundId: previousRound.id }, status: 'SUBMITTED', globalScore: { not: null } }, + select: { globalScore: true }, + }), + ctx.prisma.evaluation.findMany({ + where: { assignment: { roundId: currentRound.id }, status: 'SUBMITTED', globalScore: { not: null } }, + select: { globalScore: true }, + }), + ]) + if (prevEvals.length > 0) { + prevAvgScore = prevEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / prevEvals.length + } + if (currEvals.length > 0) { + currAvgScore = currEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / currEvals.length + } + + return { + hasPrevious: true as const, + previousRound: { + id: previousRound.id, + name: previousRound.name, + type: previousRound.roundType, + projectCount: prevProjects.length, + avgScore: prevAvgScore, + passedCount: prevPassedCount, + rejectedCount: prevRejectedCount, + }, + currentRound: { + id: currentRound.id, + name: currentRound.name, + type: currentRound.roundType, + projectCount: currProjects.length, + avgScore: currAvgScore, + }, + eliminated: prevProjects.length - currProjects.length, + categoryBreakdown, + countryAttrition, + } + }), + + getRoundAdvancementConfig: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { roundType: true, configJson: true }, + }) + + const config = round.configJson as Record | null + if (!config) return null + + return { + advanceMode: (config.advanceMode as string) ?? 'count', + startupAdvanceCount: config.startupAdvanceCount as number | undefined, + conceptAdvanceCount: config.conceptAdvanceCount as number | undefined, + advanceScoreThreshold: config.advanceScoreThreshold as number | undefined, + advancementMode: config.advancementMode as string | undefined, + advancementConfig: config.advancementConfig as Record | undefined, + } + }), + + getRecentFiles: observerProcedure + .input(z.object({ roundId: z.string(), limit: z.number().min(1).max(50).default(10) })) + .query(async ({ ctx, input }) => { + const files = await ctx.prisma.projectFile.findMany({ + where: { roundId: input.roundId }, + orderBy: { createdAt: 'desc' }, + take: input.limit, + select: { + id: true, + fileName: true, + fileType: true, + createdAt: true, + project: { + select: { id: true, title: true, teamName: true }, + }, + }, + }) + return files + }), + + getMentoringDashboard: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const assignments = await ctx.prisma.mentorAssignment.findMany({ + where: { + project: { projectRoundStates: { some: { roundId: input.roundId } } }, + }, + select: { + id: true, + mentorId: true, + projectId: true, + completionStatus: true, + mentor: { select: { id: true, name: true } }, + project: { select: { id: true, title: true, teamName: true } }, + messages: { select: { id: true } }, + }, + }) + + // Group by mentor + const mentorMap = new Map() + + let totalMessages = 0 + const activeMentorIds = new Set() + + for (const a of assignments) { + const mentorId = a.mentorId + if (!mentorMap.has(mentorId)) { + mentorMap.set(mentorId, { + mentorName: a.mentor.name ?? 'Unknown', + mentorId, + projects: [], + }) + } + const messageCount = a.messages.length + totalMessages += messageCount + if (messageCount > 0) activeMentorIds.add(mentorId) + + mentorMap.get(mentorId)!.projects.push({ + id: a.project.id, + title: a.project.title, + teamName: a.project.teamName, + messageCount, + }) + } + + return { + assignments: [...mentorMap.values()], + totalMessages, + activeMentors: activeMentorIds.size, + totalMentors: mentorMap.size, + } + }), + + getLiveFinalDashboard: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + // Get round config for observer visibility setting + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { configJson: true, roundType: true }, + }) + + let observerScoreVisibility: 'realtime' | 'after_completion' | 'hidden' = 'after_completion' + try { + if (round.roundType === 'LIVE_FINAL') { + const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig + observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion' + } + } catch { /* use default */ } + + const session = await ctx.prisma.liveVotingSession.findUnique({ + where: { roundId: input.roundId }, + select: { + id: true, + status: true, + currentProjectIndex: true, + projectOrderJson: true, + votingMode: true, + votes: { + select: { + userId: true, + projectId: true, + score: true, + }, + }, + }, + }) + + if (!session) { + return { + sessionStatus: 'NOT_STARTED' as const, + observerScoreVisibility, + voteCount: 0, + jurors: [] as { id: string; name: string; hasVoted: boolean }[], + standings: null, + } + } + + // Get jurors assigned to this round + const jurorUsers = await ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + select: { userId: true, user: { select: { id: true, name: true } } }, + distinct: ['userId'], + }) + + const voterIds = new Set(session.votes.map(v => v.userId)) + const jurors = jurorUsers.map(j => ({ + id: j.user.id, + name: j.user.name ?? 'Unknown', + hasVoted: voterIds.has(j.user.id), + })) + + // Calculate standings if visibility allows + const showScores = + observerScoreVisibility === 'realtime' || + (observerScoreVisibility === 'after_completion' && session.status === 'COMPLETED') + + let standings: { projectId: string; projectTitle: string; avgScore: number; voteCount: number }[] | null = null + + if (showScores && session.votes.length > 0) { + const projectScores = new Map() + for (const v of session.votes) { + if (v.score != null) { + if (!projectScores.has(v.projectId)) projectScores.set(v.projectId, []) + projectScores.get(v.projectId)!.push(v.score) + } + } + + const projectIds = [...projectScores.keys()] + const projects = await ctx.prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, title: true }, + }) + const projMap = new Map(projects.map(p => [p.id, p.title])) + + standings = [...projectScores.entries()] + .map(([projectId, scores]) => ({ + projectId, + projectTitle: projMap.get(projectId) ?? 'Unknown', + avgScore: scores.reduce((a, b) => a + b, 0) / scores.length, + voteCount: scores.length, + })) + .sort((a, b) => b.avgScore - a.avgScore) + } + + return { + sessionStatus: session.status, + observerScoreVisibility, + voteCount: session.votes.length, + jurors, + standings, + } + }), }) diff --git a/src/server/routers/logo.ts b/src/server/routers/logo.ts index bce2551..927b820 100644 --- a/src/server/routers/logo.ts +++ b/src/server/routers/logo.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' -import { router, adminProcedure } from '../trpc' +import { router, adminProcedure, protectedProcedure } from '../trpc' import { generateLogoKey, type StorageProviderType } from '@/lib/storage' import { getImageUploadUrl, @@ -110,9 +110,9 @@ export const logoRouter = router({ }), /** - * Get a project's logo URL + * Get a project's logo URL (any authenticated user — logos are public display data) */ - getUrl: adminProcedure + getUrl: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { return getImageUrl(ctx.prisma, logoConfig, input.projectId) diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index 61a786c..4792cc4 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -70,6 +70,7 @@ export const programRouter = router({ competitionId: round.competitionId, status: round.status, roundType: round.roundType, + sortOrder: round.sortOrder, votingEndAt: round.windowCloseAt, _count: { projects: round._count?.projectRoundStates || 0, diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index cb24d55..db51655 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' +import { getUserAvatarUrl } from '../utils/avatar-url' import { logAudit } from '../utils/audit' import { processEligibilityJob } from '../services/award-eligibility-job' import { getAwardSelectionNotificationTemplate } from '@/lib/email' @@ -481,7 +482,7 @@ export const specialAwardRouter = router({ listJurors: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { - return ctx.prisma.awardJuror.findMany({ + const jurors = await ctx.prisma.awardJuror.findMany({ where: { awardId: input.awardId }, include: { user: { @@ -496,6 +497,15 @@ export const specialAwardRouter = router({ }, }, }) + return Promise.all( + jurors.map(async (j) => ({ + ...j, + user: { + ...j.user, + avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider), + }, + })) + ) }), /** diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts index 4b37cbb..4737dad 100644 --- a/src/types/competition-configs.ts +++ b/src/types/competition-configs.ts @@ -230,6 +230,9 @@ export const LiveFinalConfigSchema = z.object({ qaDurationMinutes: z.number().int().nonnegative().default(5), revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'), + + /** Controls whether observers can see live scores: realtime, after session completes, or never */ + observerScoreVisibility: z.enum(['realtime', 'after_completion', 'hidden']).default('after_completion'), }) export type LiveFinalConfig = z.infer