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
OBSERVER
APPLICANT
AWARD_MASTER
AUDIENCE
}
@@ -1600,7 +1599,7 @@ model SpecialAward {
evaluationRoundId String?
juryGroupId String?
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)
// 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@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
{ 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> = {}

View File

@@ -58,7 +58,7 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('')
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
const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -236,7 +236,6 @@ export default function EditAwardPage({
</SelectTrigger>
<SelectContent>
<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>
</SelectContent>
</Select>

View File

@@ -408,7 +408,7 @@ export default function AwardDetailPage({
// Deferred queries - only load when needed
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' }
)
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
@@ -1518,7 +1518,6 @@ export default function AwardDetailPage({
onSubmit={async (rows) => {
await bulkInvite.mutateAsync({
awardId,
role: 'AWARD_MASTER',
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 { toast } from 'sonner'
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 = {
HARD: 'Hard Cap',
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
</div>
</div>
{/* Round assignments */}
{(group as any).rounds?.length > 0 && (
{/* Round + Special-award assignments */}
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{(group as any).rounds.map((r: any) => (
{(group as any).rounds?.map((r: any) => (
<Badge
key={r.id}
variant="outline"
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
{r.name}
</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>
)}

View File

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

View File

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

View File

@@ -97,7 +97,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default',
APPLICANT: 'secondary',
AWARD_MASTER: 'outline',
AUDIENCE: 'outline',
}
@@ -154,7 +153,7 @@ export default function MemberDetailPage() {
const handleSave = async () => {
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({
id: userId,
email: email || undefined,
@@ -622,7 +621,6 @@ export default function MemberDetailPage() {
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent>
</Select>
@@ -633,7 +631,7 @@ export default function MemberDetailPage() {
Grant additional dashboard access beyond the primary role
</p>
<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)
.map((r) => (
<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'
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 {
projectId: string
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
AWARD_MASTER: 'Award Master',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
const availableRoles = useMemo((): Role[] => {
const roles: Role[] = []
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')
return roles
}, [isSuperAdmin, isAdmin])
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
? 'SUPER_ADMIN'
: rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'AWARD_MASTER'
? 'AWARD_MASTER'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
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'
import { Badge } from '@/components/ui/badge'
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 {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
GripVertical,
ChevronDown,
Users,
Tag,
Star,
Gavel,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
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>(
null
)
const [rankedIds, setRankedIds] = useState<string[]>([])
const [justification, setJustification] = useState('')
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
const toggleExpanded = (projectId: string) => {
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null)
if (data.myVotes[0]?.justification) {
setJustification(data.myVotes[0].justification)
}
} else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
try {
await submitVote.mutateAsync({
awardId,
votes: [{ projectId: selectedProjectId }],
votes: [{
projectId: selectedProjectId,
justification: justification.trim() || undefined,
}],
})
toast.success('Vote submitted')
refetch()
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
if (!data) return null
const { award, projects, myVotes } = 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'
return (
<div className="space-y-6">
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
/>
))}
</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">
<Button
onClick={handleSubmitPickWinner}
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
{isChair && totalJurors > 1 && (
<ChairPanel
award={award}
projects={projects}
otherVotes={otherVotes}
totalJurors={totalJurors}
hasVoted={hasVoted}
onConfirm={() => confirmWinner.mutate({ awardId })}
isPending={confirmWinner.isPending}
/>
)}
</div>
) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */
@@ -332,6 +392,7 @@ type ProjectData = {
tags: string[]
logoKey?: string | null
logoUrl?: string | null
evaluationScore?: { avg: number; count: number } | null
files: Array<{
id: 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 }) {
return (
<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 && (
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
)}
@@ -469,3 +552,139 @@ function ProjectCard({
</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) {
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('AWARD_MASTER')) redirect('/award-master')
if (roles.includes('JURY_MEMBER')) redirect('/jury')
if (roles.includes('MENTOR')) redirect('/mentor' 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',
OBSERVER: 'Observer',
MENTOR: 'Mentor',
AWARD_MASTER: 'Award Master',
}
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,
LayoutDashboard,
Scale,
Trophy,
type LucideIcon,
} from 'lucide-react'
import {
@@ -33,7 +32,6 @@ export const ROLE_SWITCH_OPTIONS: Record<string, RoleSwitchOption> = {
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
}
export function useRoleSwitcher(currentBasePath: string): {

View File

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

View File

@@ -22,7 +22,7 @@ export const fileRouter = router({
})
)
.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) {
const file = await ctx.prisma.projectFile.findFirst({
@@ -321,7 +321,7 @@ export const fileRouter = router({
roundId: z.string().optional(),
}))
.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) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -393,7 +393,7 @@ export const fileRouter = router({
roundId: z.string(),
}))
.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) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -716,7 +716,7 @@ export const fileRouter = router({
})
)
.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
// 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 },
orderBy: { sortOrder: 'asc' },
},
awards: {
select: { id: true, name: true, status: true },
orderBy: { sortOrder: 'asc' },
},
members: {
take: 5,
orderBy: { joinedAt: 'asc' },

View File

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

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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 { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
@@ -267,7 +267,7 @@ export const specialAwardRouter = router({
evaluationRoundId: z.string().nullable().optional(),
juryGroupId: z.string().nullable().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 }) => {
@@ -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
.input(
z.object({
awardId: z.string(),
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
invitees: z.array(
z.object({
name: z.string().optional(),
@@ -674,7 +676,7 @@ export const specialAwardRouter = router({
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
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 }> = []
@@ -694,7 +696,7 @@ export const specialAwardRouter = router({
data: {
email: invitee.email,
name: invitee.name || null,
role: input.role,
role: 'JURY_MEMBER',
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
@@ -734,6 +736,23 @@ export const specialAwardRouter = router({
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
// never fired (no `created` branch). Send the juror-assignment
// notification so they know they were added — but only if this
@@ -760,7 +779,7 @@ export const specialAwardRouter = router({
detailsJson: {
action: 'BULK_INVITE',
awardName: award.name,
role: input.role,
juryGroupId: award.juryGroupId,
count: input.invitees.length,
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
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is a juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
@@ -864,8 +885,7 @@ export const specialAwardRouter = router({
})
}
// Fetch award, eligible projects, and votes in parallel
const [award, eligibleProjects, myVotes] = await Promise.all([
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
@@ -917,62 +937,12 @@ export const specialAwardRouter = router({
ctx.prisma.awardVote.findMany({
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({
where: { awardId: input.awardId },
select: { userId: true, isChair: true, user: { select: { name: true } } },
}),
])
// Fetch evaluation scores for eligible projects
const projectIds = eligibleProjects.map((e) => e.project.id)
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 isChair = juror.isChair || isSolo
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
@@ -1047,7 +1016,8 @@ export const specialAwardRouter = router({
// ─── 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
.input(
@@ -1057,12 +1027,12 @@ export const specialAwardRouter = router({
z.object({
projectId: z.string(),
rank: z.number().int().min(1).optional(),
justification: z.string().max(2000).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
@@ -1079,7 +1049,6 @@ export const specialAwardRouter = router({
})
}
// Verify award is open for voting
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
@@ -1091,7 +1060,6 @@ export const specialAwardRouter = router({
})
}
// Delete existing votes and create new ones
await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id },
@@ -1103,6 +1071,7 @@ export const specialAwardRouter = router({
userId: ctx.user.id,
projectId: vote.projectId,
rank: vote.rank,
justification: vote.justification || null,
},
})
),
@@ -1123,55 +1092,6 @@ export const specialAwardRouter = router({
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 ────────────────────────────────────────────────────────────
/**
@@ -1286,7 +1206,7 @@ export const specialAwardRouter = router({
/**
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
*/
confirmWinner: awardMasterProcedure
confirmWinner: protectedProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const allJurors = await ctx.prisma.awardJuror.findMany({

View File

@@ -217,8 +217,8 @@ export const userRouter = router({
list: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(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', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
@@ -364,8 +364,8 @@ export const userRouter = router({
listInvitableIds: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(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', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
search: z.string().optional(),
})
)
@@ -457,7 +457,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
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(),
maxAssignments: z.number().int().min(1).max(100).optional(),
})
@@ -528,8 +528,8 @@ export const userRouter = router({
id: z.string(),
email: z.string().email().optional(),
name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', '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(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
@@ -708,7 +708,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
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(),
// Optional pre-assignments for jury members
assignments: z
@@ -1835,7 +1835,7 @@ export const userRouter = router({
}
// 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]
return ctx.prisma.user.update({
@@ -1936,7 +1936,6 @@ export const userRouter = router({
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'AWARD_MASTER',
'APPLICANT',
'AUDIENCE',
]
@@ -2236,7 +2235,7 @@ export const userRouter = router({
* which the user has actionable work right now, or the highest-priority
* 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.
*
* Used by src/app/page.tsx to route users at login.
@@ -2253,14 +2252,6 @@ export const userRouter = router({
const PRIORITY: Entry[] = [
{ role: 'SUPER_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',
path: '/jury',

View File

@@ -407,16 +407,6 @@ export const observerProcedure = t.procedure
.use(withErrorAudit)
.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.
* All mutations auto-audited, errors tracked.

View File

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