diff --git a/prisma/migrations/20260507151706_drop_award_master_role/migration.sql b/prisma/migrations/20260507151706_drop_award_master_role/migration.sql
new file mode 100644
index 0000000..95d6e9e
--- /dev/null
+++ b/prisma/migrations/20260507151706_drop_award_master_role/migration.sql
@@ -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";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f8c4eca..c30f01b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 072ddd9..7ac1daa 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -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 = {}
diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx
index 8594a97..fe9f855 100644
--- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx
+++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx
@@ -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({
Jury Vote — tallied from all jurors
- Award Master — sponsor picks winner
Admin Decision — admin selects winner
diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx
index 6dd9f2d..e01e13b 100644
--- a/src/app/(admin)/admin/awards/[id]/page.tsx
+++ b/src/app/(admin)/admin/awards/[id]/page.tsx
@@ -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 })),
})
}}
diff --git a/src/app/(admin)/admin/juries/page.tsx b/src/app/(admin)/admin/juries/page.tsx
index 5dd787f..0f11144 100644
--- a/src/app/(admin)/admin/juries/page.tsx
+++ b/src/app/(admin)/admin/juries/page.tsx
@@ -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
- {/* Round assignments */}
- {(group as any).rounds?.length > 0 && (
+ {/* Round + Special-award assignments */}
+ {((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
- {(group as any).rounds.map((r: any) => (
+ {(group as any).rounds?.map((r: any) => (
))}
+ {(group as any).awards?.map((a: any) => (
+
+
+ {a.name}
+
+ ))}
)}
diff --git a/src/app/(admin)/admin/learning/[id]/page.tsx b/src/app/(admin)/admin/learning/[id]/page.tsx
index 7200266..8261954 100644
--- a/src/app/(admin)/admin/learning/[id]/page.tsx
+++ b/src/app/(admin)/admin/learning/[id]/page.tsx
@@ -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 =
diff --git a/src/app/(admin)/admin/learning/new/page.tsx b/src/app/(admin)/admin/learning/new/page.tsx
index b3a17ea..526fba7 100644
--- a/src/app/(admin)/admin/learning/new/page.tsx
+++ b/src/app/(admin)/admin/learning/new/page.tsx
@@ -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 =
diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx
index 66467ef..ab38f05 100644
--- a/src/app/(admin)/admin/members/[id]/page.tsx
+++ b/src/app/(admin)/admin/members/[id]/page.tsx
@@ -97,7 +97,6 @@ const roleColors: Record = {
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() {
Mentor
Observer
Applicant
- Award Master
Audience
@@ -633,7 +631,7 @@ export default function MemberDetailPage() {
Grant additional dashboard access beyond the primary role
- {(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
+ {(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
.filter((r) => r !== role)
.map((r) => (
diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx
index 2a1a5fe..0f63634 100644
--- a/src/app/(admin)/admin/members/invite/page.tsx
+++ b/src/app/(admin)/admin/members/invite/page.tsx
@@ -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 = {
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
diff --git a/src/app/(award-master)/award-master/awards/[id]/page.tsx b/src/app/(award-master)/award-master/awards/[id]/page.tsx
deleted file mode 100644
index f24adbc..0000000
--- a/src/app/(award-master)/award-master/awards/[id]/page.tsx
+++ /dev/null
@@ -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(
- null
- )
- const [expandedProjectId, setExpandedProjectId] = useState(
- 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 (
-
-
-
-
- {[...Array(6)].map((_, i) => (
-
- ))}
-
-
- )
- }
-
- 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 (
-
- {/* Back button */}
-
-
router.push('/award-master' as Route)}
- className="-ml-4"
- >
-
- Back
-
-
-
- {/* Header */}
-
-
-
- {award.name}
-
-
-
- {award.status.replace('_', ' ')}
-
- {hasVoted && !isClosed && (
-
-
- Voted
-
- )}
- {award.competition && (
-
- {award.competition.name}
-
- )}
-
- {award.criteriaText && (
-
-
-
-
- Criteria:
- {award.criteriaText}
-
-
-
- )}
-
-
- {/* Closed State */}
- {isClosed ? (
-
-
-
-
-
- Award Finalized
- {winnerProject ? (
-
-
- {winnerProject.title}
-
- {winnerProject.teamName && (
-
- {winnerProject.teamName}
-
- )}
-
- ) : (
-
- This award has been finalized
-
- )}
-
-
- ) : (
- <>
- {/* Project Grid */}
-
-
- Eligible Projects ({projects.length})
-
- {isVotingOpen && (
-
- Click a project to select it as your pick and expand details
-
- )}
-
- {projects.map((project) => (
-
-
handleProjectClick(project.id)}
- >
-
-
-
-
-
- {project.title}
-
- {project.teamName && (
-
- {project.teamName}
-
- )}
-
-
- {expandedProjectId === project.id ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {project.competitionCategory && (
-
- {project.competitionCategory.replace(/_/g, ' ')}
-
- )}
- {project.country && (
-
-
-
- )}
- {project.evaluationScore && (
-
-
- Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
- {project.evaluationScore.count}{' '}
- {project.evaluationScore.count === 1
- ? 'review'
- : 'reviews'}
- )
-
- )}
- {selectedProjectId === project.id && (
-
-
- Selected
-
- )}
-
-
-
-
- {/* Expanded Project Detail */}
- {expandedProjectId === project.id && (
-
-
- {project.description && (
-
-
-
- Description
-
-
- {project.description}
-
-
- )}
-
- {award.evaluationRoundId && (
-
- )}
-
- {project.evaluationScore && (
-
-
-
-
-
-
- Evaluation Score
-
-
- {project.evaluationScore.avg.toFixed(1)} / 10
-
-
- Based on {project.evaluationScore.count}{' '}
- {project.evaluationScore.count === 1
- ? 'evaluation'
- : 'evaluations'}
-
-
-
-
-
- )}
-
-
- )}
-
- ))}
-
-
-
- {/* Vote Section */}
- {isVotingOpen && (
-
-
- Your Vote
-
- {hasVoted
- ? 'You can update your vote until the award is finalized'
- : 'Select a project above and submit your vote'}
-
-
-
- {selectedProject ? (
-
-
- Your selection
-
-
{selectedProject.title}
- {selectedProject.teamName && (
-
- {selectedProject.teamName}
-
- )}
-
- ) : (
-
- No project selected. Click a project card above to select it.
-
- )}
-
-
-
-
-
- {submitVote.isPending ? (
-
- ) : (
-
- )}
- {hasVoted ? 'Update Vote' : 'Submit Vote'}
-
-
-
-
- )}
-
- {/* Chair Section */}
- {isChair && isVotingOpen && (
- <>
-
-
-
-
-
-
- Team Votes
-
-
- As chair, you can view team votes and confirm the winner
-
-
-
- {otherVotes.length > 0 ? (
-
- {otherVotes.map((vote) => {
- const votedProject = projects.find(
- (p) => p.id === vote.projectId
- )
- return (
-
-
-
- {vote.userName || 'Anonymous Juror'}
-
-
- voted for
-
-
-
- {votedProject?.title || 'Unknown project'}
-
- {vote.justification && (
-
- “{vote.justification}”
-
- )}
-
- )
- })}
-
- ) : (
-
- Waiting for other team members to vote
-
- )}
-
- {/* Vote tally */}
-
-
Vote Summary
-
- {otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
- {totalJurors} jurors have voted
-
- {(() => {
- const allVotes = [
- ...otherVotes.map((v) => v.projectId),
- ...(hasVoted && myVotes[0]
- ? [myVotes[0].projectId]
- : []),
- ]
- const tally = new Map
()
- 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 (
-
- {sorted.map(([pid, count]) => {
- const proj = projects.find((p) => p.id === pid)
- return (
-
- {proj?.title || 'Unknown'}
-
- {count} {count === 1 ? 'vote' : 'votes'}
-
-
- )
- })}
-
- )
- })()}
-
-
- {/* Confirm Winner button */}
-
-
-
-
- {confirmWinner.isPending ? (
-
- ) : (
-
- )}
- Confirm Winner
-
-
-
-
-
- Confirm Award Winner
-
-
- This will finalize the winner and close the award.
- This cannot be undone.
-
-
-
- Cancel
-
- Confirm Winner
-
-
-
-
-
-
-
- >
- )}
- >
- )}
-
- )
-}
diff --git a/src/app/(award-master)/award-master/page.tsx b/src/app/(award-master)/award-master/page.tsx
deleted file mode 100644
index 41345be..0000000
--- a/src/app/(award-master)/award-master/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {[...Array(2)].map((_, i) => (
-
- ))}
-
-
- )
- }
-
- return (
-
-
-
- Award Master Dashboard
-
-
- Review eligible projects and select award winners
-
-
-
- {awards && awards.length > 0 ? (
-
- {awards.map((award) => (
-
-
-
-
-
-
- {award.name}
-
-
- {award.status.replace('_', ' ')}
-
-
- {award.description && (
-
- {award.description}
-
- )}
-
-
-
- {award._count.eligibilities} eligible projects
-
-
-
-
- ))}
-
- ) : (
-
-
-
- No awards assigned
-
- You will see your awards here when they are assigned to you
-
-
-
- )}
-
- )
-}
diff --git a/src/app/(award-master)/layout.tsx b/src/app/(award-master)/layout.tsx
deleted file mode 100644
index 92caca2..0000000
--- a/src/app/(award-master)/layout.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/src/app/(jury)/jury/awards/[id]/page.tsx b/src/app/(jury)/jury/awards/[id]/page.tsx
index ffb2c22..a31ff27 100644
--- a/src/app/(jury)/jury/awards/[id]/page.tsx
+++ b/src/app/(jury)/jury/awards/[id]/page.tsx
@@ -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(
null
)
const [rankedIds, setRankedIds] = useState([])
+ const [justification, setJustification] = useState('')
const [expandedProjects, setExpandedProjects] = useState>(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 (
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
-
/>
))}
+
+ {selectedProjectId && (
+
+
+ Justification (optional)
+
+ Visible to the jury chair when they finalize the award.
+
+
+
+
+
+ )}
+
+
+ {isChair && totalJurors > 1 && (
+ confirmWinner.mutate({ awardId })}
+ isPending={confirmWinner.isPending}
+ />
+ )}
) : 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 (
+ {project.evaluationScore && (
+
+
+
+
+ {project.evaluationScore.avg.toFixed(1)} / 10
+
+
+ from {project.evaluationScore.count}{' '}
+ {project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
+
+
+
+ )}
+
{project.description && (
{project.description}
)}
@@ -469,3 +552,139 @@ function ProjectCard({
)
}
+
+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
()
+ 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 (
+
+
+
+
+ Chair tools
+
+
+ {votedCount} of {totalJurors} jurors have voted. As the chair you
+ can review their picks and finalize the award.
+
+
+
+ {ranked.length === 0 ? (
+
+ No other juror votes yet.
+
+ ) : (
+
+
+ Tally so far
+
+ {ranked.map(({ project, votes }) => (
+
+ {project!.title}
+ {votes} {votes === 1 ? 'vote' : 'votes'}
+
+ ))}
+
+ )}
+
+ {otherVotes.length > 0 && (
+
+
+ Justifications
+
+ {otherVotes.map((v) => {
+ const project = projectMap.get(v.projectId)
+ return (
+
+
+
+ {v.userName || 'Anonymous juror'}
+
+
+ → {project?.title || 'Unknown project'}
+
+
+ {v.justification && (
+
+ {v.justification}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ {!isClosed && (
+
+
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ Confirm winner & close award
+
+
+
+
+ Finalize the award?
+
+ The project with the most votes will be set as the
+ winner. If there's a tie, your own vote breaks it.
+ Voting will close immediately and this can't be
+ reopened from this page.
+
+
+
+ Cancel
+
+ Confirm
+
+
+
+
+
+ )}
+
+ {!hasVoted && (
+
+ You must submit your own vote before finalizing.
+
+ )}
+
+
+ )
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 8abb6c3..48b3865 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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)
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx
index 0249a04..89d8e41 100644
--- a/src/components/layouts/admin-sidebar.tsx
+++ b/src/components/layouts/admin-sidebar.tsx
@@ -167,7 +167,6 @@ const roleLabels: Record = {
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
MENTOR: 'Mentor',
- AWARD_MASTER: 'Award Master',
}
export function AdminSidebar({ user }: AdminSidebarProps) {
diff --git a/src/components/layouts/award-master-nav.tsx b/src/components/layouts/award-master-nav.tsx
deleted file mode 100644
index 4760316..0000000
--- a/src/components/layouts/award-master-nav.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/src/components/layouts/role-switcher.tsx b/src/components/layouts/role-switcher.tsx
index a0507b7..7b218d4 100644
--- a/src/components/layouts/role-switcher.tsx
+++ b/src/components/layouts/role-switcher.tsx
@@ -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 = {
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): {
diff --git a/src/lib/auth-redirect.ts b/src/lib/auth-redirect.ts
index 17bf629..a7d5b7d 100644
--- a/src/lib/auth-redirect.ts
+++ b/src/lib/auth-redirect.ts
@@ -10,7 +10,6 @@ const ROLE_DASHBOARDS: Record = {
MENTOR: '/mentor',
OBSERVER: '/observer',
APPLICANT: '/applicant',
- AWARD_MASTER: '/award-master',
}
export async function requireRole(...allowedRoles: UserRole[]) {
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index ca43edb..44c93e6 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -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
diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts
index 5e80fec..308e2bf 100644
--- a/src/server/routers/juryGroup.ts
+++ b/src/server/routers/juryGroup.ts
@@ -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' },
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index b725c36..e39c598 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -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> = []
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> = []
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts
index aa36784..4782a03 100644
--- a/src/server/routers/specialAward.ts
+++ b/src/server/routers/specialAward.ts
@@ -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 = {}
@@ -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({
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 660ab9c..15687ce 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -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',
diff --git a/src/server/trpc.ts b/src/server/trpc.ts
index 94f7523..e7cd231 100644
--- a/src/server/trpc.ts
+++ b/src/server/trpc.ts
@@ -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.
diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts
index 95bd107..7732826 100644
--- a/src/types/competition-configs.ts
+++ b/src/types/competition-configs.ts
@@ -347,7 +347,6 @@ export const AwardStatusSchema = z.enum([
])
export const AwardDecisionModeSchema = z.enum([
'JURY_VOTE',
- 'AWARD_MASTER_DECISION',
'ADMIN_DECISION',
])