refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s

The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.

This collapses the role into JURY_MEMBER + isChair:

- specialAward.getMyAwardDetail now returns evaluation scores, chair
  visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
  protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
  a juryGroupId, also adds them to that JuryGroup so they appear on
  the round-page jury panel
- jury award page renders justification, eval-score badges, and a
  chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
  shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
  enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
  recreates the UserRole enum without the value

Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 15:21:09 +02:00
parent a9116b5833
commit 7bc2b84d1d
26 changed files with 344 additions and 912 deletions

View File

@@ -0,0 +1,29 @@
-- Drops AWARD_MASTER from the UserRole enum.
--
-- Any row still holding AWARD_MASTER is demoted to JURY_MEMBER (singular role)
-- or filtered out of the roles[] array (multi-role) before the enum swap, so
-- the type alteration is safe even if the prod migration was missed.
UPDATE "User" SET role = 'JURY_MEMBER' WHERE role = 'AWARD_MASTER';
UPDATE "User" SET roles = array_remove(roles, 'AWARD_MASTER') WHERE 'AWARD_MASTER' = ANY(roles);
CREATE TYPE "UserRole_new" AS ENUM (
'SUPER_ADMIN',
'PROGRAM_ADMIN',
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'APPLICANT',
'AUDIENCE'
);
ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;
ALTER TABLE "User"
ALTER COLUMN role TYPE "UserRole_new" USING role::text::"UserRole_new";
ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'APPLICANT';
ALTER TABLE "User"
ALTER COLUMN roles TYPE "UserRole_new"[] USING roles::text[]::"UserRole_new"[];
DROP TYPE "UserRole";
ALTER TYPE "UserRole_new" RENAME TO "UserRole";

View File

@@ -29,7 +29,6 @@ enum UserRole {
MENTOR MENTOR
OBSERVER OBSERVER
APPLICANT APPLICANT
AWARD_MASTER
AUDIENCE AUDIENCE
} }
@@ -1600,7 +1599,7 @@ model SpecialAward {
evaluationRoundId String? evaluationRoundId String?
juryGroupId String? juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION" decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
shortlistSize Int @default(10) shortlistSize Int @default(10)
// Eligibility job tracking // Eligibility job tracking

View File

@@ -317,7 +317,6 @@ async function main() {
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' }, { email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' }, { email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' }, { email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
] ]
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}

View File

@@ -58,7 +58,7 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('') const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('') const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN') const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE') const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
// Helper to format date for datetime-local input // Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => { const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -236,7 +236,6 @@ export default function EditAwardPage({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote tallied from all jurors</SelectItem> <SelectItem value="JURY_VOTE">Jury Vote tallied from all jurors</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">Award Master sponsor picks winner</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision admin selects winner</SelectItem> <SelectItem value="ADMIN_DECISION">Admin Decision admin selects winner</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -408,7 +408,7 @@ export default function AwardDetailPage({
// Deferred queries - only load when needed // Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery( const { data: allUsers } = trpc.user.list.useQuery(
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] }, { page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
{ enabled: activeTab === 'jurors' } { enabled: activeTab === 'jurors' }
) )
const { data: juryGroups } = trpc.juryGroup.list.useQuery( const { data: juryGroups } = trpc.juryGroup.list.useQuery(
@@ -1518,7 +1518,6 @@ export default function AwardDetailPage({
onSubmit={async (rows) => { onSubmit={async (rows) => {
await bulkInvite.mutateAsync({ await bulkInvite.mutateAsync({
awardId, awardId,
role: 'AWARD_MASTER',
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })), invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
}) })
}} }}

View File

@@ -35,7 +35,7 @@ import {
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn, formatEnumLabel } from '@/lib/utils' import { cn, formatEnumLabel } from '@/lib/utils'
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react' import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
const capModeLabels = { const capModeLabels = {
HARD: 'Hard Cap', HARD: 'Hard Cap',
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
</div> </div>
</div> </div>
{/* Round assignments */} {/* Round + Special-award assignments */}
{(group as any).rounds?.length > 0 && ( {((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(group as any).rounds.map((r: any) => ( {(group as any).rounds?.map((r: any) => (
<Badge <Badge
key={r.id} key={r.id}
variant="outline" variant="outline"
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
{r.name} {r.name}
</Badge> </Badge>
))} ))}
{(group as any).awards?.map((a: any) => (
<Badge
key={a.id}
variant="outline"
className={cn(
'text-[10px] gap-1',
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
)}
>
<Trophy className="h-2.5 w-2.5" />
{a.name}
</Badge>
))}
</div> </div>
)} )}

View File

@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
{ value: 'MENTOR', label: 'Mentors' }, { value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' }, { value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' }, { value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
] ]
type AccessRule = type AccessRule =

View File

@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
{ value: 'MENTOR', label: 'Mentors' }, { value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' }, { value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' }, { value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
] ]
type AccessRule = type AccessRule =

View File

@@ -97,7 +97,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
PROGRAM_ADMIN: 'default', PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default', SUPER_ADMIN: 'default',
APPLICANT: 'secondary', APPLICANT: 'secondary',
AWARD_MASTER: 'outline',
AUDIENCE: 'outline', AUDIENCE: 'outline',
} }
@@ -154,7 +153,7 @@ export default function MemberDetailPage() {
const handleSave = async () => { const handleSave = async () => {
try { try {
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'> const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
await updateUser.mutateAsync({ await updateUser.mutateAsync({
id: userId, id: userId,
email: email || undefined, email: email || undefined,
@@ -622,7 +621,6 @@ export default function MemberDetailPage() {
<SelectItem value="MENTOR">Mentor</SelectItem> <SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem> <SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem> <SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem> <SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -633,7 +631,7 @@ export default function MemberDetailPage() {
Grant additional dashboard access beyond the primary role Grant additional dashboard access beyond the primary role
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const) {(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
.filter((r) => r !== role) .filter((r) => r !== role)
.map((r) => ( .map((r) => (
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer"> <label key={r} className="flex items-center gap-2 text-sm cursor-pointer">

View File

@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete' type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment { interface Assignment {
projectId: string projectId: string
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin', SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin', PROGRAM_ADMIN: 'Program Admin',
AWARD_MASTER: 'Award Master',
JURY_MEMBER: 'Jury Member', JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor', MENTOR: 'Mentor',
OBSERVER: 'Observer', OBSERVER: 'Observer',
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
const availableRoles = useMemo((): Role[] => { const availableRoles = useMemo((): Role[] => {
const roles: Role[] = [] const roles: Role[] = []
if (isSuperAdmin) roles.push('SUPER_ADMIN') if (isSuperAdmin) roles.push('SUPER_ADMIN')
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER') if (isAdmin) roles.push('PROGRAM_ADMIN')
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER') roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
return roles return roles
}, [isSuperAdmin, isAdmin]) }, [isSuperAdmin, isAdmin])
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
? 'SUPER_ADMIN' ? 'SUPER_ADMIN'
: rawRole === 'PROGRAM_ADMIN' : rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN' ? 'PROGRAM_ADMIN'
: rawRole === 'AWARD_MASTER' : rawRole === 'MENTOR'
? 'AWARD_MASTER' ? 'MENTOR'
: rawRole === 'MENTOR' : rawRole === 'OBSERVER'
? 'MENTOR' ? 'OBSERVER'
: rawRole === 'OBSERVER' : 'JURY_MEMBER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email) const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin

View File

@@ -1,581 +0,0 @@
'use client'
import { use, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
ChevronDown,
ChevronUp,
FileText,
Star,
Users,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import { ProjectLogo } from '@/components/shared/project-logo'
export default function AwardMasterVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
// State
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
null
)
const [justification, setJustification] = useState('')
// Queries & mutations
const utils = trpc.useUtils()
const { data, isLoading } =
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Winner confirmed and award closed')
},
onError: (err) => toast.error(err.message),
})
// Initialize selection from existing vote
const initializedRef = useRef(false)
if (data && !initializedRef.current && data.myVotes.length > 0) {
initializedRef.current = true
setSelectedProjectId(data.myVotes[0].projectId)
if (data.myVotes[0].justification) {
setJustification(data.myVotes[0].justification)
}
}
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-6 w-72" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-44" />
))}
</div>
</div>
)
}
if (!data) return null
// Destructure data
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
const isClosed = award.status === 'CLOSED'
const selectedProject = projects.find((p) => p.id === selectedProjectId)
// Toggle project expansion
const handleProjectClick = (projectId: string) => {
if (isVotingOpen) setSelectedProjectId(projectId)
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
}
// Submit vote handler
const handleSubmitVote = () => {
if (!selectedProjectId) return
submitVote.mutate({
awardId,
projectId: selectedProjectId,
justification: justification.trim() || undefined,
})
}
// Confirm winner handler
const handleConfirmWinner = () => {
confirmWinner.mutate({ awardId })
}
// Find the winner project for closed state
const winnerProject = isClosed
? projects.find((p) => p.id === award.winnerProjectId)
: null
return (
<div className="space-y-6">
{/* Back button */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => router.push('/award-master' as Route)}
className="-ml-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="mt-1 flex items-center gap-2">
<Badge
variant={
isVotingOpen
? 'default'
: isClosed
? 'secondary'
: 'outline'
}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && !isClosed && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
{award.competition && (
<span className="text-sm text-muted-foreground">
{award.competition.name}
</span>
)}
</div>
{award.criteriaText && (
<Card className="mt-3 bg-muted/30">
<CardContent className="py-3 px-4">
<p className="text-sm text-muted-foreground leading-relaxed">
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
<span className="font-medium text-foreground">Criteria: </span>
{award.criteriaText}
</p>
</CardContent>
</Card>
)}
</div>
{/* Closed State */}
{isClosed ? (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
<Trophy className="h-12 w-12 text-amber-500" />
</div>
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
{winnerProject ? (
<div className="mt-3 space-y-1">
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
{winnerProject.title}
</p>
{winnerProject.teamName && (
<p className="text-sm text-muted-foreground">
{winnerProject.teamName}
</p>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">
This award has been finalized
</p>
)}
</CardContent>
</Card>
) : (
<>
{/* Project Grid */}
<div>
<h2 className="text-lg font-semibold mb-3">
Eligible Projects ({projects.length})
</h2>
{isVotingOpen && (
<p className="text-sm text-muted-foreground mb-4">
Click a project to select it as your pick and expand details
</p>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div
key={project.id}
className={cn(
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
)}
>
<Card
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => handleProjectClick(project.id)}
>
<CardHeader className="pb-2">
<div className="flex items-start gap-3">
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-base">
{project.title}
</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">
{project.teamName}
</CardDescription>
)}
</div>
<div className="ml-2 shrink-0">
{expandedProjectId === project.id ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-1.5">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace(/_/g, ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
<CountryDisplay country={project.country} />
</Badge>
)}
{project.evaluationScore && (
<Badge
variant="secondary"
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<Star className="mr-0.5 h-3 w-3" />
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
{project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'review'
: 'reviews'}
)
</Badge>
)}
{selectedProjectId === project.id && (
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
<CheckCircle2 className="mr-0.5 h-3 w-3" />
Selected
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Expanded Project Detail */}
{expandedProjectId === project.id && (
<Card className="mt-2 border-dashed">
<CardContent className="space-y-4 py-4">
{project.description && (
<div>
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Description
</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
{project.description}
</p>
</div>
)}
{award.evaluationRoundId && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Documents
</h4>
<ProjectFilesSection
projectId={project.id}
roundId={award.evaluationRoundId}
/>
</div>
)}
{project.evaluationScore && (
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm font-medium">
Evaluation Score
</p>
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
{project.evaluationScore.avg.toFixed(1)} / 10
</p>
<p className="text-xs text-muted-foreground">
Based on {project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'evaluation'
: 'evaluations'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
))}
</div>
</div>
{/* Vote Section */}
{isVotingOpen && (
<Card>
<CardHeader>
<CardTitle className="text-base">Your Vote</CardTitle>
<CardDescription>
{hasVoted
? 'You can update your vote until the award is finalized'
: 'Select a project above and submit your vote'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedProject ? (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">
Your selection
</p>
<p className="font-semibold">{selectedProject.title}</p>
{selectedProject.teamName && (
<p className="text-sm text-muted-foreground">
{selectedProject.teamName}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No project selected. Click a project card above to select it.
</p>
)}
<div className="space-y-2">
<label
htmlFor="justification"
className="text-sm font-medium"
>
Justification
</label>
<Textarea
id="justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
placeholder="Why did you choose this project? (optional)"
maxLength={2000}
rows={4}
/>
<p className="text-xs text-muted-foreground text-right">
{justification.length} / 2000
</p>
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitVote}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Chair Section */}
{isChair && isVotingOpen && (
<>
<Separator />
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
Team Votes
</CardTitle>
<CardDescription>
As chair, you can view team votes and confirm the winner
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{otherVotes.length > 0 ? (
<div className="space-y-3">
{otherVotes.map((vote) => {
const votedProject = projects.find(
(p) => p.id === vote.projectId
)
return (
<div
key={vote.userId}
className="rounded-lg border p-3 space-y-1"
>
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
{vote.userName || 'Anonymous Juror'}
</p>
<Badge variant="outline" className="text-xs">
voted for
</Badge>
</div>
<p className="text-sm font-semibold">
{votedProject?.title || 'Unknown project'}
</p>
{vote.justification && (
<p className="text-sm text-muted-foreground italic">
&ldquo;{vote.justification}&rdquo;
</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Waiting for other team members to vote
</p>
)}
{/* Vote tally */}
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-sm font-medium">Vote Summary</p>
<p className="text-sm text-muted-foreground">
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
{totalJurors} jurors have voted
</p>
{(() => {
const allVotes = [
...otherVotes.map((v) => v.projectId),
...(hasVoted && myVotes[0]
? [myVotes[0].projectId]
: []),
]
const tally = new Map<string, number>()
for (const pid of allVotes) {
tally.set(pid, (tally.get(pid) || 0) + 1)
}
const sorted = [...tally.entries()].sort(
(a, b) => b[1] - a[1]
)
if (sorted.length === 0) return null
return (
<div className="mt-2 space-y-1">
{sorted.map(([pid, count]) => {
const proj = projects.find((p) => p.id === pid)
return (
<div
key={pid}
className="flex items-center justify-between text-sm"
>
<span>{proj?.title || 'Unknown'}</span>
<Badge variant="secondary" className="text-xs">
{count} {count === 1 ? 'vote' : 'votes'}
</Badge>
</div>
)
})}
</div>
)
})()}
</div>
{/* Confirm Winner button */}
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="default"
disabled={!hasVoted || confirmWinner.isPending}
>
{confirmWinner.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Confirm Winner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Confirm Award Winner
</AlertDialogTitle>
<AlertDialogDescription>
This will finalize the winner and close the award.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmWinner}>
Confirm Winner
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</>
)}
</>
)}
</div>
)
}

View File

@@ -1,91 +0,0 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy } from 'lucide-react'
export default function AwardMasterDashboard() {
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Award Master Dashboard
</h1>
<p className="text-muted-foreground">
Review eligible projects and select award winners
</p>
</div>
{awards && awards.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{awards.map((award) => (
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
{award.name}
</CardTitle>
<Badge
variant={
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
}
>
{award.status.replace('_', ' ')}
</Badge>
</div>
{award.description && (
<CardDescription className="line-clamp-2">
{award.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{award._count.eligibilities} eligible projects
</p>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No awards assigned</p>
<p className="text-sm text-muted-foreground">
You will see your awards here when they are assigned to you
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,24 +0,0 @@
import { requireRole } from '@/lib/auth-redirect'
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
export const dynamic = 'force-dynamic'
export default async function AwardMasterLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
return (
<div className="min-h-screen bg-background">
<AwardMasterNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6 lg:py-8">{children}</main>
</div>
)
}

View File

@@ -13,16 +13,29 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
ArrowLeft, ArrowLeft,
Trophy, Trophy,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
GripVertical,
ChevronDown, ChevronDown,
Users, Users,
Tag, Tag,
Star,
Gavel,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display' import { CountryDisplay } from '@/components/shared/country-display'
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
utils.specialAward.getMyAwardDetail.invalidate({ awardId }) utils.specialAward.getMyAwardDetail.invalidate({ awardId })
}, },
}) })
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
toast.success('Winner confirmed and award closed')
},
onError: (err) => toast.error(err.message),
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null null
) )
const [rankedIds, setRankedIds] = useState<string[]>([]) const [rankedIds, setRankedIds] = useState<string[]>([])
const [justification, setJustification] = useState('')
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set()) const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
const toggleExpanded = (projectId: string) => { const toggleExpanded = (projectId: string) => {
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) { if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') { if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null) setSelectedProjectId(data.myVotes[0]?.projectId || null)
if (data.myVotes[0]?.justification) {
setJustification(data.myVotes[0].justification)
}
} else if (data.award.scoringMode === 'RANKED') { } else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes] const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0)) .sort((a, b) => (a.rank || 0) - (b.rank || 0))
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
try { try {
await submitVote.mutateAsync({ await submitVote.mutateAsync({
awardId, awardId,
votes: [{ projectId: selectedProjectId }], votes: [{
projectId: selectedProjectId,
justification: justification.trim() || undefined,
}],
}) })
toast.success('Vote submitted') toast.success('Vote submitted')
refetch() refetch()
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
if (!data) return null if (!data) return null
const { award, projects, myVotes } = data const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
const hasVoted = myVotes.length > 0 const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN' const isVotingOpen = award.status === 'VOTING_OPEN'
const isClosed = award.status === 'CLOSED'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)} isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)} onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)} onToggleExpand={() => toggleExpanded(project.id)}
/> />
))} ))}
</div> </div>
{selectedProjectId && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Justification (optional)</CardTitle>
<CardDescription>
Visible to the jury chair when they finalize the award.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
rows={3}
maxLength={2000}
placeholder="Why this project? (optional)"
value={justification}
onChange={(e) => setJustification(e.target.value)}
/>
</CardContent>
</Card>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={handleSubmitPickWinner} onClick={handleSubmitPickWinner}
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
{hasVoted ? 'Update Vote' : 'Submit Vote'} {hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</div> </div>
{isChair && totalJurors > 1 && (
<ChairPanel
award={award}
projects={projects}
otherVotes={otherVotes}
totalJurors={totalJurors}
hasVoted={hasVoted}
onConfirm={() => confirmWinner.mutate({ awardId })}
isPending={confirmWinner.isPending}
/>
)}
</div> </div>
) : award.scoringMode === 'RANKED' ? ( ) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */ /* RANKED Mode */
@@ -332,6 +392,7 @@ type ProjectData = {
tags: string[] tags: string[]
logoKey?: string | null logoKey?: string | null
logoUrl?: string | null logoUrl?: string | null
evaluationScore?: { avg: number; count: number } | null
files: Array<{ files: Array<{
id: string id: string
fileName: string fileName: string
@@ -355,9 +416,31 @@ type ProjectData = {
}> }>
} }
type OtherVote = {
userId: string
userName: string | null
projectId: string
justification: string | null
}
function ProjectDetails({ project }: { project: ProjectData }) { function ProjectDetails({ project }: { project: ProjectData }) {
return ( return (
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2"> <div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
{project.evaluationScore && (
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<Star className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="text-sm">
<span className="font-semibold text-blue-700 dark:text-blue-300">
{project.evaluationScore.avg.toFixed(1)} / 10
</span>
<span className="text-muted-foreground ml-2">
from {project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
</span>
</div>
</div>
)}
{project.description && ( {project.description && (
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p> <p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
)} )}
@@ -469,3 +552,139 @@ function ProjectCard({
</Card> </Card>
) )
} }
function ChairPanel({
award,
projects,
otherVotes,
totalJurors,
hasVoted,
onConfirm,
isPending,
}: {
award: { id: string; status: string }
projects: ProjectData[]
otherVotes: OtherVote[]
totalJurors: number
hasVoted: boolean
onConfirm: () => void
isPending: boolean
}) {
const projectMap = new Map(projects.map((p) => [p.id, p]))
const tally = new Map<string, number>()
for (const v of otherVotes) {
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
}
const ranked = Array.from(tally.entries())
.map(([projectId, votes]) => ({
project: projectMap.get(projectId),
votes,
}))
.filter((r) => r.project)
.sort((a, b) => b.votes - a.votes)
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
const isClosed = award.status === 'CLOSED'
return (
<Card className="border-amber-200 dark:border-amber-900">
<CardHeader>
<div className="flex items-center gap-2">
<Gavel className="h-5 w-5 text-amber-600" />
<CardTitle className="text-base">Chair tools</CardTitle>
</div>
<CardDescription>
{votedCount} of {totalJurors} jurors have voted. As the chair you
can review their picks and finalize the award.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{ranked.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No other juror votes yet.
</p>
) : (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Tally so far
</p>
{ranked.map(({ project, votes }) => (
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm font-medium truncate">{project!.title}</span>
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
</div>
))}
</div>
)}
{otherVotes.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Justifications
</p>
{otherVotes.map((v) => {
const project = projectMap.get(v.projectId)
return (
<div key={v.userId} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{v.userName || 'Anonymous juror'}
</span>
<span className="text-xs text-muted-foreground truncate">
{project?.title || 'Unknown project'}
</span>
</div>
{v.justification && (
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
{v.justification}
</p>
)}
</div>
)
})}
</div>
)}
{!isClosed && (
<div className="flex justify-end pt-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={!hasVoted || isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Confirm winner & close award
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
<AlertDialogDescription>
The project with the most votes will be set as the
winner. If there&apos;s a tie, your own vote breaks it.
Voting will close immediately and this can&apos;t be
reopened from this page.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{!hasVoted && (
<p className="text-xs text-muted-foreground text-right">
You must submit your own vote before finalizing.
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -20,7 +20,6 @@ export default async function HomePage() {
if (session?.user) { if (session?.user) {
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole] const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin') if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
if (roles.includes('AWARD_MASTER')) redirect('/award-master')
if (roles.includes('JURY_MEMBER')) redirect('/jury') if (roles.includes('JURY_MEMBER')) redirect('/jury')
if (roles.includes('MENTOR')) redirect('/mentor' as Route) if (roles.includes('MENTOR')) redirect('/mentor' as Route)
if (roles.includes('APPLICANT')) redirect('/applicant' as Route) if (roles.includes('APPLICANT')) redirect('/applicant' as Route)

View File

@@ -167,7 +167,6 @@ const roleLabels: Record<string, string> = {
JURY_MEMBER: 'Jury Member', JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer', OBSERVER: 'Observer',
MENTOR: 'Mentor', MENTOR: 'Mentor',
AWARD_MASTER: 'Award Master',
} }
export function AdminSidebar({ user }: AdminSidebarProps) { export function AdminSidebar({ user }: AdminSidebarProps) {

View File

@@ -1,23 +0,0 @@
'use client'
import { Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface AwardMasterNavProps {
user: RoleNavUser
}
export function AwardMasterNav({ user }: AwardMasterNavProps) {
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/award-master', icon: Home },
]
return (
<RoleNav
navigation={navigation}
roleName="Award Master"
user={user}
basePath="/award-master"
/>
)
}

View File

@@ -10,7 +10,6 @@ import {
Handshake, Handshake,
LayoutDashboard, LayoutDashboard,
Scale, Scale,
Trophy,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { import {
@@ -33,7 +32,6 @@ export const ROLE_SWITCH_OPTIONS: Record<string, RoleSwitchOption> = {
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
} }
export function useRoleSwitcher(currentBasePath: string): { export function useRoleSwitcher(currentBasePath: string): {

View File

@@ -10,7 +10,6 @@ const ROLE_DASHBOARDS: Record<string, string> = {
MENTOR: '/mentor', MENTOR: '/mentor',
OBSERVER: '/observer', OBSERVER: '/observer',
APPLICANT: '/applicant', APPLICANT: '/applicant',
AWARD_MASTER: '/award-master',
} }
export async function requireRole(...allowedRoles: UserRole[]) { export async function requireRole(...allowedRoles: UserRole[]) {

View File

@@ -22,7 +22,7 @@ export const fileRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) { if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({ const file = await ctx.prisma.projectFile.findFirst({
@@ -321,7 +321,7 @@ export const fileRouter = router({
roundId: z.string().optional(), roundId: z.string().optional(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) { if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -393,7 +393,7 @@ export const fileRouter = router({
roundId: z.string(), roundId: z.string(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) { if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -716,7 +716,7 @@ export const fileRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
// For non-admin/observer callers, mirror the per-round scope used by // For non-admin/observer callers, mirror the per-round scope used by
// file.getDownloadUrl: a juror assigned to round N may only pull URLs // file.getDownloadUrl: a juror assigned to round N may only pull URLs

View File

@@ -102,6 +102,10 @@ export const juryGroupRouter = router({
select: { id: true, name: true, roundType: true, status: true }, select: { id: true, name: true, roundType: true, status: true },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}, },
awards: {
select: { id: true, name: true, status: true },
orderBy: { sortOrder: 'asc' },
},
members: { members: {
take: 5, take: 5,
orderBy: { joinedAt: 'asc' }, orderBy: { joinedAt: 'asc' },

View File

@@ -177,11 +177,11 @@ export const projectRouter = router({
] ]
} }
// Per-role visibility filters. Admin / Observer / Award master see all // Per-role visibility filters. Admin / Observer see all (these roles
// (these roles are designed for cross-program oversight). Other roles // are designed for cross-program oversight). Other roles are scoped to
// are scoped to projects they have a relationship with. // projects they have a relationship with.
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER') const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
if (!isAdmin && !isObserverLevel) { if (!isAdmin && !isObserverLevel) {
const orClauses: Array<Record<string, unknown>> = [] const orClauses: Array<Record<string, unknown>> = []
if (userHasRole(ctx.user, 'JURY_MEMBER')) { if (userHasRole(ctx.user, 'JURY_MEMBER')) {
@@ -534,10 +534,10 @@ export const projectRouter = router({
// ProjectTag table may not exist yet // ProjectTag table may not exist yet
} }
// Per-role access check. Admin / Observer / Award master can read any // Per-role access check. Admin / Observer can read any project.
// project. Jury / Mentor / Applicant must have a relationship to it. // Jury / Mentor / Applicant must have a relationship to it.
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER') const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
if (!isAdmin && !isObserverLevel) { if (!isAdmin && !isObserverLevel) {
const checks: Array<Promise<unknown>> = [] const checks: Array<Promise<unknown>> = []
if (userHasRole(ctx.user, 'JURY_MEMBER')) { if (userHasRole(ctx.user, 'JURY_MEMBER')) {

View File

@@ -1,7 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url' import { getUserAvatarUrl } from '../utils/avatar-url'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job' import { processEligibilityJob } from '../services/award-eligibility-job'
@@ -267,7 +267,7 @@ export const specialAwardRouter = router({
evaluationRoundId: z.string().nullable().optional(), evaluationRoundId: z.string().nullable().optional(),
juryGroupId: z.string().nullable().optional(), juryGroupId: z.string().nullable().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(), eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(), decisionMode: z.enum(['JURY_VOTE', 'ADMIN_DECISION']).nullable().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -656,13 +656,15 @@ export const specialAwardRouter = router({
}), }),
/** /**
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails * Bulk invite new users as award jurors. Creates JURY_MEMBER accounts,
* attaches them as AwardJuror, and (if the award has an assigned jury
* group) also adds them as JuryGroupMember so they appear on the
* round-page jury panel alongside existing members.
*/ */
bulkInviteJurors: adminProcedure bulkInviteJurors: adminProcedure
.input( .input(
z.object({ z.object({
awardId: z.string(), awardId: z.string(),
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
invitees: z.array( invitees: z.array(
z.object({ z.object({
name: z.string().optional(), name: z.string().optional(),
@@ -674,7 +676,7 @@ export const specialAwardRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({ const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId }, where: { id: input.awardId },
select: { id: true, name: true }, select: { id: true, name: true, juryGroupId: true },
}) })
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = [] const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
@@ -694,7 +696,7 @@ export const specialAwardRouter = router({
data: { data: {
email: invitee.email, email: invitee.email,
name: invitee.name || null, name: invitee.name || null,
role: input.role, role: 'JURY_MEMBER',
status: 'INVITED', status: 'INVITED',
inviteToken, inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs), inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
@@ -734,6 +736,23 @@ export const specialAwardRouter = router({
create: { awardId: input.awardId, userId: user.id }, create: { awardId: input.awardId, userId: user.id },
}) })
if (award.juryGroupId) {
await ctx.prisma.juryGroupMember.upsert({
where: {
juryGroupId_userId: {
juryGroupId: award.juryGroupId,
userId: user.id,
},
},
update: {},
create: {
juryGroupId: award.juryGroupId,
userId: user.id,
role: 'MEMBER',
},
})
}
// For existing-user invitees the new-account invite email above // For existing-user invitees the new-account invite email above
// never fired (no `created` branch). Send the juror-assignment // never fired (no `created` branch). Send the juror-assignment
// notification so they know they were added — but only if this // notification so they know they were added — but only if this
@@ -760,7 +779,7 @@ export const specialAwardRouter = router({
detailsJson: { detailsJson: {
action: 'BULK_INVITE', action: 'BULK_INVITE',
awardName: award.name, awardName: award.name,
role: input.role, juryGroupId: award.juryGroupId,
count: input.invitees.length, count: input.invitees.length,
results, results,
}, },
@@ -842,12 +861,14 @@ export const specialAwardRouter = router({
}), }),
/** /**
* Get award detail for voting (jury view) * Get award detail for voting (jury view). Includes upstream evaluation
* scores per project (when the award is anchored to an evaluation round)
* and — for the chair — the votes cast by other jurors so they can
* tie-break and finalize the award.
*/ */
getMyAwardDetail: protectedProcedure getMyAwardDetail: protectedProcedure
.input(z.object({ awardId: z.string() })) .input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Verify user is a juror
const juror = await ctx.prisma.awardJuror.findUnique({ const juror = await ctx.prisma.awardJuror.findUnique({
where: { where: {
awardId_userId: { awardId_userId: {
@@ -864,8 +885,7 @@ export const specialAwardRouter = router({
}) })
} }
// Fetch award, eligible projects, and votes in parallel const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
const [award, eligibleProjects, myVotes] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({ ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId }, where: { id: input.awardId },
}), }),
@@ -917,62 +937,12 @@ export const specialAwardRouter = router({
ctx.prisma.awardVote.findMany({ ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id }, where: { awardId: input.awardId, userId: ctx.user.id },
}), }),
])
const projectsRaw = eligibleProjects.map((e) => e.project)
const projectsWithLogos = await attachProjectLogoUrls(projectsRaw)
return {
award,
projects: projectsWithLogos,
myVotes,
}
}),
/**
* Enhanced award detail for Award Master — includes project scores and chair vote visibility
*/
getMyAwardDetailEnhanced: awardMasterProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: { awardId: input.awardId, userId: ctx.user.id },
},
})
if (!juror) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not assigned to this award' })
}
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
include: {
competition: { select: { id: true, name: true } },
},
}),
ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
include: {
project: {
select: {
id: true, title: true, teamName: true, description: true,
competitionCategory: true, country: true, tags: true,
logoKey: true, logoProvider: true,
},
},
},
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
ctx.prisma.awardJuror.findMany({ ctx.prisma.awardJuror.findMany({
where: { awardId: input.awardId }, where: { awardId: input.awardId },
select: { userId: true, isChair: true, user: { select: { name: true } } }, select: { userId: true, isChair: true, user: { select: { name: true } } },
}), }),
]) ])
// Fetch evaluation scores for eligible projects
const projectIds = eligibleProjects.map((e) => e.project.id) const projectIds = eligibleProjects.map((e) => e.project.id)
const projectScores: Record<string, { avg: number; count: number }> = {} const projectScores: Record<string, { avg: number; count: number }> = {}
@@ -1007,7 +977,6 @@ export const specialAwardRouter = router({
} }
} }
// Chair sees other votes
const isSolo = allJurors.length === 1 const isSolo = allJurors.length === 1
const isChair = juror.isChair || isSolo const isChair = juror.isChair || isSolo
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = [] let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
@@ -1047,7 +1016,8 @@ export const specialAwardRouter = router({
// ─── Voting ───────────────────────────────────────────────────────────── // ─── Voting ─────────────────────────────────────────────────────────────
/** /**
* Submit vote (PICK_WINNER or RANKED) * Submit vote (PICK_WINNER or RANKED). For PICK_WINNER, jurors may attach
* an optional justification — visible to the chair when they review votes.
*/ */
submitVote: protectedProcedure submitVote: protectedProcedure
.input( .input(
@@ -1057,12 +1027,12 @@ export const specialAwardRouter = router({
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
rank: z.number().int().min(1).optional(), rank: z.number().int().min(1).optional(),
justification: z.string().max(2000).optional(),
}) })
), ),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Verify juror
const juror = await ctx.prisma.awardJuror.findUnique({ const juror = await ctx.prisma.awardJuror.findUnique({
where: { where: {
awardId_userId: { awardId_userId: {
@@ -1079,7 +1049,6 @@ export const specialAwardRouter = router({
}) })
} }
// Verify award is open for voting
const award = await ctx.prisma.specialAward.findUniqueOrThrow({ const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId }, where: { id: input.awardId },
}) })
@@ -1091,7 +1060,6 @@ export const specialAwardRouter = router({
}) })
} }
// Delete existing votes and create new ones
await ctx.prisma.$transaction([ await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({ ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id }, where: { awardId: input.awardId, userId: ctx.user.id },
@@ -1103,6 +1071,7 @@ export const specialAwardRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
projectId: vote.projectId, projectId: vote.projectId,
rank: vote.rank, rank: vote.rank,
justification: vote.justification || null,
}, },
}) })
), ),
@@ -1123,55 +1092,6 @@ export const specialAwardRouter = router({
return { submitted: input.votes.length } return { submitted: input.votes.length }
}), }),
/**
* Submit award master vote with optional justification (PICK_WINNER only)
*/
submitAwardMasterVote: awardMasterProcedure
.input(z.object({
awardId: z.string(),
projectId: z.string(),
justification: z.string().max(2000).optional(),
}))
.mutation(async ({ ctx, input }) => {
const juror = await ctx.prisma.awardJuror.findUnique({
where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id } },
})
if (!juror) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
}
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
if (award.status !== 'VOTING_OPEN') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not open' })
}
await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
ctx.prisma.awardVote.create({
data: {
awardId: input.awardId,
userId: ctx.user.id,
projectId: input.projectId,
justification: input.justification || null,
},
}),
])
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'AwardVote',
entityId: input.awardId,
detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' },
})
return { submitted: true }
}),
// ─── Results ──────────────────────────────────────────────────────────── // ─── Results ────────────────────────────────────────────────────────────
/** /**
@@ -1286,7 +1206,7 @@ export const specialAwardRouter = router({
/** /**
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award * Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
*/ */
confirmWinner: awardMasterProcedure confirmWinner: protectedProcedure
.input(z.object({ awardId: z.string() })) .input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const allJurors = await ctx.prisma.awardJuror.findMany({ const allJurors = await ctx.prisma.awardJuror.findMany({

View File

@@ -217,8 +217,8 @@ export const userRouter = router({
list: adminProcedure list: adminProcedure
.input( .input(
z.object({ z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(), search: z.string().optional(),
page: z.number().int().min(1).default(1), page: z.number().int().min(1).default(1),
@@ -364,8 +364,8 @@ export const userRouter = router({
listInvitableIds: adminProcedure listInvitableIds: adminProcedure
.input( .input(
z.object({ z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
search: z.string().optional(), search: z.string().optional(),
}) })
) )
@@ -457,7 +457,7 @@ export const userRouter = router({
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(), name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional(), maxAssignments: z.number().int().min(1).max(100).optional(),
}) })
@@ -528,8 +528,8 @@ export const userRouter = router({
id: z.string(), id: z.string(),
email: z.string().email().optional(), email: z.string().email().optional(),
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
@@ -708,7 +708,7 @@ export const userRouter = router({
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(), name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members // Optional pre-assignments for jury members
assignments: z assignments: z
@@ -1835,7 +1835,7 @@ export const userRouter = router({
} }
// Set primary role to highest privilege role // Set primary role to highest privilege role
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE'] const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0] const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
return ctx.prisma.user.update({ return ctx.prisma.user.update({
@@ -1936,7 +1936,6 @@ export const userRouter = router({
'JURY_MEMBER', 'JURY_MEMBER',
'MENTOR', 'MENTOR',
'OBSERVER', 'OBSERVER',
'AWARD_MASTER',
'APPLICANT', 'APPLICANT',
'AUDIENCE', 'AUDIENCE',
] ]
@@ -2236,7 +2235,7 @@ export const userRouter = router({
* which the user has actionable work right now, or the highest-priority * which the user has actionable work right now, or the highest-priority
* role they hold (static fallback) if nothing is actionable. * role they hold (static fallback) if nothing is actionable.
* *
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > AWARD_MASTER > JURY_MEMBER > * Priority order: SUPER_ADMIN > PROGRAM_ADMIN > JURY_MEMBER >
* MENTOR > APPLICANT > OBSERVER > AUDIENCE. * MENTOR > APPLICANT > OBSERVER > AUDIENCE.
* *
* Used by src/app/page.tsx to route users at login. * Used by src/app/page.tsx to route users at login.
@@ -2253,14 +2252,6 @@ export const userRouter = router({
const PRIORITY: Entry[] = [ const PRIORITY: Entry[] = [
{ role: 'SUPER_ADMIN', path: '/admin', predicate: () => true }, { role: 'SUPER_ADMIN', path: '/admin', predicate: () => true },
{ role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true }, { role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true },
{
role: 'AWARD_MASTER',
path: '/award-master',
predicate: async () => {
const cnt = await ctx.prisma.awardJuror.count({ where: { userId: user.id } })
return cnt > 0
},
},
{ {
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
path: '/jury', path: '/jury',

View File

@@ -407,16 +407,6 @@ export const observerProcedure = t.procedure
.use(withErrorAudit) .use(withErrorAudit)
.use(withMutationAudit) .use(withMutationAudit)
/**
* Award master procedure - requires AWARD_MASTER role (or admin).
* AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked.
*/
export const awardMasterProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
/** /**
* Audience procedure - requires any authenticated user. * Audience procedure - requires any authenticated user.
* All mutations auto-audited, errors tracked. * All mutations auto-audited, errors tracked.

View File

@@ -347,7 +347,6 @@ export const AwardStatusSchema = z.enum([
]) ])
export const AwardDecisionModeSchema = z.enum([ export const AwardDecisionModeSchema = z.enum([
'JURY_VOTE', 'JURY_VOTE',
'AWARD_MASTER_DECISION',
'ADMIN_DECISION', 'ADMIN_DECISION',
]) ])