Multi-role members, round detail UI overhaul, dashboard jury progress, and submit bug fix
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Add roles UserRole[] to User model with migration + backfill from existing role column
- Update auth JWT/session to propagate roles array with [role] fallback for stale tokens
- Update tRPC hasRole() middleware and add userHasRole() helper for inline role checks
- Update ~15 router inline checks and ~13 DB queries to use roles array
- Add updateRoles admin mutation with SUPER_ADMIN guard and priority-based primary role
- Add role switcher UI in admin sidebar and role-nav for multi-role users
- Remove redundant stats cards from round detail, add window dates to header banner
- Merge Members section into JuryProgressTable with inline cap editor and remove buttons
- Reorder round detail assignments tab: Progress > Score Dist > Assignments > Coverage > Jury Group
- Make score distribution fill full vertical height, reassignment history always open
- Add per-juror progress bars to admin dashboard ActiveRoundPanel for EVALUATION rounds
- Fix evaluation submit bug: use isSubmitting state instead of startMutation.isPending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:44:55 +01:00
parent 230347005c
commit f3fd9eebee
25 changed files with 963 additions and 714 deletions

View File

@@ -0,0 +1,59 @@
-- DropForeignKey
ALTER TABLE "ConflictOfInterest" DROP CONSTRAINT "ConflictOfInterest_roundId_fkey";
-- DropForeignKey
ALTER TABLE "Project" DROP CONSTRAINT "Project_programId_fkey";
-- DropForeignKey
ALTER TABLE "TaggingJob" DROP CONSTRAINT "TaggingJob_roundId_fkey";
-- DropIndex
DROP INDEX "DiscussionComment_discussionId_createdAt_idx";
-- DropIndex
DROP INDEX "EvaluationDiscussion_status_idx";
-- DropIndex
DROP INDEX "LiveVote_isAudienceVote_idx";
-- DropIndex
DROP INDEX "Message_scheduledAt_idx";
-- DropIndex
DROP INDEX "MessageRecipient_messageId_idx";
-- DropIndex
DROP INDEX "MessageRecipient_userId_isRead_idx";
-- DropIndex
DROP INDEX "Project_programId_roundId_idx";
-- DropIndex
DROP INDEX "Project_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_projectId_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_roundId_idx";
-- DropIndex
DROP INDEX "TaggingJob_roundId_idx";
-- DropIndex
DROP INDEX "WebhookDelivery_createdAt_idx";
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "UserRole"[] DEFAULT ARRAY[]::"UserRole"[];
-- Backfill: populate roles array from existing role column
UPDATE "User" SET "roles" = ARRAY["role"]::"UserRole"[];
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageTemplate" ADD CONSTRAINT "MessageTemplate_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" RENAME TO "DeliberationVote_sessionId_juryMemberId_projectId_runoffRou_key";

View File

@@ -303,6 +303,7 @@ model User {
name String? name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER) role UserRole @default(JURY_MEMBER)
roles UserRole[] @default([])
status UserStatus @default(INVITED) status UserStatus @default(INVITED)
expertiseTags String[] @default([]) expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit maxAssignments Int? // Per-round limit

View File

@@ -347,6 +347,7 @@ async function main() {
email: account.email, email: account.email,
name: account.name, name: account.name,
role: account.role, role: account.role,
roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null, passwordHash: isSuperAdmin ? passwordHash : null,
mustSetPassword: !isSuperAdmin, mustSetPassword: !isSuperAdmin,
@@ -385,6 +386,7 @@ async function main() {
email: j.email, email: j.email,
name: j.name, name: j.name,
role: UserRole.JURY_MEMBER, role: UserRole.JURY_MEMBER,
roles: [UserRole.JURY_MEMBER],
status: UserStatus.NONE, status: UserStatus.NONE,
country: j.country, country: j.country,
expertiseTags: j.tags, expertiseTags: j.tags,
@@ -416,6 +418,7 @@ async function main() {
email: m.email, email: m.email,
name: m.name, name: m.name,
role: UserRole.MENTOR, role: UserRole.MENTOR,
roles: [UserRole.MENTOR],
status: UserStatus.NONE, status: UserStatus.NONE,
country: m.country, country: m.country,
expertiseTags: m.tags, expertiseTags: m.tags,
@@ -444,6 +447,7 @@ async function main() {
email: o.email, email: o.email,
name: o.name, name: o.name,
role: UserRole.OBSERVER, role: UserRole.OBSERVER,
roles: [UserRole.OBSERVER],
status: UserStatus.NONE, status: UserStatus.NONE,
country: o.country, country: o.country,
}, },
@@ -545,6 +549,7 @@ async function main() {
email, email,
name: name || `Applicant ${rowIdx + 1}`, name: name || `Applicant ${rowIdx + 1}`,
role: UserRole.APPLICANT, role: UserRole.APPLICANT,
roles: [UserRole.APPLICANT],
status: UserStatus.NONE, status: UserStatus.NONE,
phoneNumber: phone, phoneNumber: phone,
country, country,

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const evaluationIdRef = useRef<string | null>(null) const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false) const isSubmittedRef = useRef(false)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null) const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
@@ -318,10 +319,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
autosaveTimerRef.current = null autosaveTimerRef.current = null
} }
isSubmittingRef.current = true isSubmittingRef.current = true
setIsSubmitting(true)
if (!myAssignment) { if (!myAssignment) {
toast.error('Assignment not found') toast.error('Assignment not found')
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
@@ -335,16 +338,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (c.type === 'numeric' && (val === undefined || val === null)) { if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`) toast.error(`Please score "${c.label}"`)
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
if (c.type === 'boolean' && val === undefined) { if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`) toast.error(`Please answer "${c.label}"`)
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) { if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`) toast.error(`Please fill in "${c.label}"`)
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
} }
@@ -355,6 +361,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (isNaN(score) || score < 1 || score > 10) { if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10') toast.error('Please enter a valid score between 1 and 10')
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
} }
@@ -363,6 +370,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (!binaryDecision) { if (!binaryDecision) {
toast.error('Please select accept or reject') toast.error('Please select accept or reject')
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
} }
@@ -371,6 +379,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) { if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`) toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
return return
} }
} }
@@ -414,6 +423,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
} catch { } catch {
// Error toast already handled by onError callback // Error toast already handled by onError callback
isSubmittingRef.current = false isSubmittingRef.current = false
setIsSubmitting(false)
} }
} }
@@ -878,7 +888,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={submitMutation.isPending || startMutation.isPending} disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light" className="bg-brand-blue hover:bg-brand-blue-light"
> >
<Send className="mr-2 h-4 w-4" /> <Send className="mr-2 h-4 w-4" />

View File

@@ -13,7 +13,8 @@ export default async function MentorLayout({
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN') const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
// Check if user has completed onboarding (for mentors) // Check if user has completed onboarding (for mentors)
if (session.user.role === 'MENTOR') { const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },

View File

@@ -13,14 +13,44 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react' import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
import { TransferAssignmentsDialog } from './transfer-assignments-dialog' import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
export type JuryProgressTableMember = {
id: string
userId: string
name: string
email: string
maxAssignmentsOverride: number | null
}
export type JuryProgressTableProps = { export type JuryProgressTableProps = {
roundId: string roundId: string
members?: JuryProgressTableMember[]
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
onAddMember?: () => void
} }
export function JuryProgressTable({ roundId }: JuryProgressTableProps) { export function JuryProgressTable({
roundId,
members,
onSaveCap,
onRemoveMember,
onAddMember,
}: JuryProgressTableProps) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null) const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
@@ -52,12 +82,30 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const hasMembersData = members !== undefined
return ( return (
<> <>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle> <div className="flex items-center justify-between">
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription> <div>
<CardTitle className="text-base">
{hasMembersData ? 'Jury Members & Progress' : 'Jury Progress'}
</CardTitle>
<CardDescription>
{hasMembersData
? 'Manage jury members, caps, and evaluation progress per juror.'
: 'Evaluation completion per juror. Click the mail icon to notify an individual juror.'}
</CardDescription>
</div>
{onAddMember && (
<Button size="sm" onClick={onAddMember}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
@@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)} {[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div> </div>
) : !workload || workload.length === 0 ? ( ) : !workload || workload.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6"> hasMembersData && members && members.length > 0 ? (
No assignments yet // Show members-only view when there are members but no assignments yet
</p> <div className="space-y-1">
{members.map((member, idx) => (
<MemberOnlyRow
key={member.id}
member={member}
idx={idx}
roundId={roundId}
onSaveCap={onSaveCap}
onRemoveMember={onRemoveMember}
notifyMutation={notifyMutation}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
{hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'}
</p>
)
) : ( ) : (
<div className="space-y-3 max-h-[350px] overflow-y-auto"> <div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
{workload.map((juror) => { {workload.map((juror) => {
const pct = juror.completionRate const pct = juror.completionRate
const barGradient = pct === 100 const barGradient = pct === 100
@@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
? 'bg-gradient-to-r from-amber-400 to-amber-600' ? 'bg-gradient-to-r from-amber-400 to-amber-600'
: 'bg-gray-300' : 'bg-gray-300'
// Find the corresponding member entry for cap editing
const member = members?.find((m) => m.userId === juror.id)
return ( return (
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group"> <div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-medium truncate max-w-[50%]">{juror.name}</span> <span className="font-medium truncate max-w-[140px]">{juror.name}</span>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{member && onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<span className="text-muted-foreground tabular-nums"> <span className="text-muted-foreground tabular-nums">
{juror.completed}/{juror.assigned} ({pct}%) {juror.completed}/{juror.assigned} ({pct}%)
</span> </span>
@@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent> <TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{member && onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div> </div>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">
@@ -178,3 +286,96 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
</> </>
) )
} }
// Sub-component for member-only rows (no workload data yet)
function MemberOnlyRow({
member,
idx,
roundId,
onSaveCap,
onRemoveMember,
notifyMutation,
}: {
member: JuryProgressTableMember
idx: number
roundId: string
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
notifyMutation: ReturnType<typeof trpc.assignment.notifySingleJurorOfAssignments.useMutation>
}) {
return (
<div
className={cn(
'flex items-center justify-between py-2 px-2 rounded-md transition-colors text-xs',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{member.name}</p>
<p className="text-muted-foreground truncate">{member.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMutation.isPending && notifyMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
}

View File

@@ -1,39 +1,30 @@
'use client' 'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { History, ChevronRight } from 'lucide-react' import { History } from 'lucide-react'
export type ReassignmentHistoryProps = { export type ReassignmentHistoryProps = {
roundId: string roundId: string
} }
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) { export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
const [expanded, setExpanded] = useState(false)
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery( const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
{ roundId }, { roundId },
{ enabled: expanded },
) )
return ( return (
<Card> <Card>
<CardHeader <CardHeader>
className="cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
Reassignment History Reassignment History
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
</CardTitle> </CardTitle>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription> <CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
</CardHeader> </CardHeader>
{expanded && ( <CardContent>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)} {[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
@@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
</div> </div>
)} )}
</CardContent> </CardContent>
)}
</Card> </Card>
) )
} }

View File

@@ -364,9 +364,16 @@ export function MembersContent() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={roleColors[user.role] || 'secondary'}> <div className="flex flex-wrap gap-1">
{user.role.replace(/_/g, ' ')} {((user as unknown as { roles?: string[] }).roles?.length
</Badge> ? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? ( {user.expertiseTags && user.expertiseTags.length > 0 ? (
@@ -469,9 +476,16 @@ export function MembersContent() {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span> <span className="text-muted-foreground">Role</span>
<Badge variant={roleColors[user.role] || 'secondary'}> <div className="flex flex-wrap gap-1 justify-end">
{user.role.replace(/_/g, ' ')} {((user as unknown as { roles?: string[] }).roles?.length
</Badge> ? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span> <span className="text-muted-foreground">Assignments</span>

View File

@@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
[dist]) [dist])
return ( return (
<Card> <Card className="flex flex-col">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Score Distribution</CardTitle> <CardTitle className="text-base">Score Distribution</CardTitle>
<CardDescription> <CardDescription>
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'} {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col flex-1 pb-4">
{isLoading ? ( {isLoading ? (
<div className="flex items-end gap-1 h-32"> <div className="flex items-end gap-1 flex-1 min-h-[120px]">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)} {Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
</div> </div>
) : !dist || dist.totalEvaluations === 0 ? ( ) : !dist || dist.totalEvaluations === 0 ? (
@@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
No evaluations submitted yet No evaluations submitted yet
</p> </p>
) : ( ) : (
<div className="flex gap-1 h-32"> <div className="flex gap-1 flex-1 min-h-[120px]">
{dist.globalDistribution.map((bucket) => { {dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100 const heightPct = (bucket.count / maxCount) * 100
return ( return (

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { import {
@@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -19,6 +21,7 @@ import {
import { StatusBadge } from '@/components/shared/status-badge' import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils' import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config' import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
import { trpc } from '@/lib/trpc/client'
export type PipelineRound = { export type PipelineRound = {
id: string id: string
@@ -138,6 +141,80 @@ function ProjectStateBar({
) )
} }
function EvaluationRoundContent({ round }: { round: PipelineRound }) {
const [showAll, setShowAll] = useState(false)
const { data: workload, isLoading: isLoadingWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId: round.id },
{ enabled: round.roundType === 'EVALUATION' }
)
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
{/* Per-juror progress */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Jury Progress</span>
{workload && workload.length > 8 && (
<button
onClick={() => setShowAll(!showAll)}
className="text-xs text-primary hover:underline"
>
{showAll ? 'Show less' : `Show all (${workload.length})`}
</button>
)}
</div>
{isLoadingWorkload ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : workload && workload.length > 0 ? (
<div className="space-y-1">
{(showAll ? workload : workload.slice(0, 8)).map((juror) => {
const pct = juror.assigned > 0 ? (juror.completed / juror.assigned) * 100 : 0
return (
<div key={juror.id} className="flex items-center gap-2">
<span className="max-w-[140px] truncate text-xs">{juror.name}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{juror.completed}/{juror.assigned}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No jurors assigned yet</p>
)}
</div>
</div>
)
}
function RoundTypeContent({ round }: { round: PipelineRound }) { function RoundTypeContent({ round }: { round: PipelineRound }) {
const { projectStates } = round const { projectStates } = round
@@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
) )
} }
case 'EVALUATION': { case 'EVALUATION':
const pct = return <EvaluationRoundContent round={round} />
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
</div>
)
}
case 'SUBMISSION': case 'SUBMISSION':
return ( return (

View File

@@ -35,7 +35,10 @@ import {
LayoutTemplate, LayoutTemplate,
Layers, Layers,
Scale, Scale,
Eye,
ArrowRightLeft,
} from 'lucide-react' } from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { getInitials } from '@/lib/utils' import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo' import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector' import { EditionSelector } from '@/components/shared/edition-selector'
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
PROGRAM_ADMIN: 'Program Admin', PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member', JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer', OBSERVER: 'Observer',
MENTOR: 'Mentor',
AWARD_MASTER: 'Award Master',
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
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 },
} }
export function AdminSidebar({ user }: AdminSidebarProps) { export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isSuperAdmin = user.role === 'SUPER_ADMIN' const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User' const roleLabel = roleLabels[user.role || ''] || 'User'
// Roles the user can switch to (non-admin roles they hold)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter(
([role]) => userRoles.includes(role as UserRole)
)
// Build dynamic admin nav with current edition's apply page // Build dynamic admin nav with current edition's apply page
const dynamicAdminNav = adminNavigation.map((item) => { const dynamicAdminNav = adminNavigation.map((item) => {
if (item.name === 'Apply Page' && currentEdition?.id) { if (item.name === 'Apply Page' && currentEdition?.id) {
@@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
</div>
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link
href={opt.path as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<opt.icon className="h-4 w-4 text-muted-foreground" />
<span>{opt.label}</span>
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator className="my-1" /> <DropdownMenuSeparator className="my-1" />
<DropdownMenuItem <DropdownMenuItem

View File

@@ -17,7 +17,11 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import type { Route } from 'next' import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react' import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo' import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell' import { NotificationBell } from '@/components/shared/notification-bell'
@@ -45,6 +49,15 @@ type RoleNavProps = {
editionSelector?: React.ReactNode editionSelector?: React.ReactNode
} }
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
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 },
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean { function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href)) return pathname === href || (href !== basePath && pathname.startsWith(href))
} }
@@ -52,7 +65,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) { export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
// Roles the user can switch to (excluding current view)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath)
// Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin)
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
return ( return (
<header className="sticky top-0 z-40 border-b bg-card"> <header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app"> <div className="container-app">
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
Settings Settings
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator />
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
<opt.icon className="mr-2 h-4 w-4" />
{opt.label}
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })} onClick={() => signOut({ callbackUrl: '/login' })}
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{editionSelector} {editionSelector}
</div> </div>
)} )}
{switchableRoles.length > 0 && (
<div className="border-t pt-4 mt-4 space-y-1">
<p className="flex items-center gap-1.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
{switchableRoles.map(([, opt]) => (
<Link
key={opt.path}
href={opt.path as Route}
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<opt.icon className="h-4 w-4" />
{opt.label}
</Link>
))}
</div>
)}
<div className="border-t pt-4 mt-4"> <div className="border-t pt-4 mt-4">
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -19,10 +19,11 @@ export async function requireRole(...allowedRoles: UserRole[]) {
redirect('/login') redirect('/login')
} }
const userRole = session.user.role // Use roles array, fallback to [role] for stale JWT tokens
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (!allowedRoles.includes(userRole)) { if (!allowedRoles.some(r => userRoles.includes(r))) {
const dashboard = ROLE_DASHBOARDS[userRole] const dashboard = ROLE_DASHBOARDS[session.user.role]
redirect((dashboard || '/login') as Route) redirect((dashboard || '/login') as Route)
} }

View File

@@ -9,12 +9,14 @@ declare module 'next-auth' {
email: string email: string
name?: string | null name?: string | null
role: UserRole role: UserRole
roles: UserRole[]
mustSetPassword?: boolean mustSetPassword?: boolean
} }
} }
interface User { interface User {
role?: UserRole role?: UserRole
roles?: UserRole[]
mustSetPassword?: boolean mustSetPassword?: boolean
} }
} }
@@ -23,6 +25,7 @@ declare module '@auth/core/jwt' {
interface JWT { interface JWT {
id: string id: string
role: UserRole role: UserRole
roles?: UserRole[]
mustSetPassword?: boolean mustSetPassword?: boolean
} }
} }

View File

@@ -54,6 +54,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: true, email: true,
name: true, name: true,
role: true, role: true,
roles: true,
status: true, status: true,
inviteTokenExpiresAt: true, inviteTokenExpiresAt: true,
}, },
@@ -95,6 +96,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
roles: user.roles.length ? user.roles : [user.role],
mustSetPassword: true, mustSetPassword: true,
} }
} }
@@ -120,6 +122,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: true, email: true,
name: true, name: true,
role: true, role: true,
roles: true,
status: true, status: true,
passwordHash: true, passwordHash: true,
mustSetPassword: true, mustSetPassword: true,
@@ -183,6 +186,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
roles: user.roles.length ? user.roles : [user.role],
mustSetPassword: user.mustSetPassword, mustSetPassword: user.mustSetPassword,
} }
}, },
@@ -195,6 +199,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (user) { if (user) {
token.id = user.id as string token.id = user.id as string
token.role = user.role as UserRole token.role = user.role as UserRole
token.roles = user.roles?.length ? user.roles : [user.role as UserRole]
token.mustSetPassword = user.mustSetPassword token.mustSetPassword = user.mustSetPassword
} }
@@ -202,10 +207,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (trigger === 'update') { if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user.findUnique({
where: { id: token.id as string }, where: { id: token.id as string },
select: { role: true, mustSetPassword: true }, select: { role: true, roles: true, mustSetPassword: true },
}) })
if (dbUser) { if (dbUser) {
token.role = dbUser.role token.role = dbUser.role
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
token.mustSetPassword = dbUser.mustSetPassword token.mustSetPassword = dbUser.mustSetPassword
} }
} }
@@ -216,6 +222,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (token && session.user) { if (token && session.user) {
session.user.id = token.id as string session.user.id = token.id as string
session.user.role = token.role as UserRole session.user.role = token.role as UserRole
session.user.roles = (token.roles as UserRole[]) ?? [token.role as UserRole]
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
} }
return session return session
@@ -231,6 +238,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
passwordHash: true, passwordHash: true,
mustSetPassword: true, mustSetPassword: true,
role: true, role: true,
roles: true,
}, },
}) })
@@ -250,6 +258,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (dbUser) { if (dbUser) {
user.id = dbUser.id user.id = dbUser.id
user.role = dbUser.role user.role = dbUser.role
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
} }
} }
@@ -309,7 +318,9 @@ export async function requireAuth() {
// Helper to require specific role(s) // Helper to require specific role(s)
export async function requireRole(...roles: UserRole[]) { export async function requireRole(...roles: UserRole[]) {
const session = await requireAuth() const session = await requireAuth()
if (!roles.includes(session.user.role)) { // Use roles array, fallback to [role] for stale JWT tokens
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (!roles.some(r => userRoles.includes(r))) {
throw new Error('Forbidden') throw new Error('Forbidden')
} }
return session return session

View File

@@ -743,7 +743,7 @@ export const analyticsRouter = router({
select: { userId: true }, select: { userId: true },
distinct: ['userId'], distinct: ['userId'],
}).then((rows) => rows.length) }).then((rows) => rows.length)
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), : ctx.prisma.user.count({ where: { roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalFilter }), ctx.prisma.evaluation.count({ where: evalFilter }),
ctx.prisma.assignment.count({ where: assignmentFilter }), ctx.prisma.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({ ctx.prisma.evaluation.findMany({

View File

@@ -1,6 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url' import { getUserAvatarUrl } from '../utils/avatar-url'
import { import {
generateAIAssignments, generateAIAssignments,
@@ -114,7 +114,7 @@ export async function reassignAfterCOI(params: {
? await prisma.user.findMany({ ? await prisma.user.findMany({
where: { where: {
id: { in: activeRoundJurorIds }, id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
}, },
select: { id: true, name: true, email: true, maxAssignments: true }, select: { id: true, name: true, email: true, maxAssignments: true },
@@ -340,7 +340,7 @@ async function reassignDroppedJurorAssignments(params: {
? await prisma.user.findMany({ ? await prisma.user.findMany({
where: { where: {
id: { in: activeRoundJurorIds }, id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
}, },
select: { id: true, name: true, email: true, maxAssignments: true }, select: { id: true, name: true, email: true, maxAssignments: true },
@@ -627,7 +627,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
const jurors = await prisma.user.findMany({ const jurors = await prisma.user.findMany({
where: { where: {
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
}, },
@@ -899,7 +899,7 @@ export const assignmentRouter = router({
// Verify access // Verify access
if ( if (
ctx.user.role === 'JURY_MEMBER' && userHasRole(ctx.user, 'JURY_MEMBER') &&
assignment.userId !== ctx.user.id assignment.userId !== ctx.user.id
) { ) {
throw new TRPCError({ throw new TRPCError({
@@ -1322,7 +1322,7 @@ export const assignmentRouter = router({
const jurors = await ctx.prisma.user.findMany({ const jurors = await ctx.prisma.user.findMany({
where: { where: {
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
}, },
@@ -2199,7 +2199,7 @@ export const assignmentRouter = router({
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId) const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
candidateJurors = ids.length > 0 candidateJurors = ids.length > 0
? await ctx.prisma.user.findMany({ ? await ctx.prisma.user.findMany({
where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' }, where: { id: { in: ids }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' },
select: { id: true, name: true, email: true, maxAssignments: true }, select: { id: true, name: true, email: true, maxAssignments: true },
}) })
: [] : []
@@ -2427,7 +2427,7 @@ export const assignmentRouter = router({
? await ctx.prisma.user.findMany({ ? await ctx.prisma.user.findMany({
where: { where: {
id: { in: activeRoundJurorIds }, id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
}, },
select: { id: true, name: true, email: true, maxAssignments: true }, select: { id: true, name: true, email: true, maxAssignments: true },
@@ -2890,7 +2890,7 @@ export const assignmentRouter = router({
? await ctx.prisma.user.findMany({ ? await ctx.prisma.user.findMany({
where: { where: {
id: { in: activeRoundJurorIds }, id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
}, },
select: { id: true, name: true, email: true, maxAssignments: true }, select: { id: true, name: true, email: true, maxAssignments: true },

View File

@@ -185,7 +185,7 @@ export const dashboardRouter = router({
// 9. Total jurors // 9. Total jurors
ctx.prisma.user.count({ ctx.prisma.user.count({
where: { where: {
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: { in: ['ACTIVE', 'INVITED', 'NONE'] }, status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } }, assignments: { some: { round: { competition: { programId: editionId } } } },
}, },
@@ -194,7 +194,7 @@ export const dashboardRouter = router({
// 10. Active jurors // 10. Active jurors
ctx.prisma.user.count({ ctx.prisma.user.count({
where: { where: {
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } }, assignments: { some: { round: { competition: { programId: editionId } } } },
}, },

View File

@@ -1,6 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { reassignAfterCOI } from './assignment' import { reassignAfterCOI } from './assignment'
@@ -20,7 +20,7 @@ export const evaluationRouter = router({
}) })
if ( if (
ctx.user.role === 'JURY_MEMBER' && userHasRole(ctx.user, 'JURY_MEMBER') &&
assignment.userId !== ctx.user.id assignment.userId !== ctx.user.id
) { ) {
throw new TRPCError({ code: 'FORBIDDEN' }) throw new TRPCError({ code: 'FORBIDDEN' })

View File

@@ -2,7 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url' import { getUserAvatarUrl } from '../utils/avatar-url'
import { import {
notifyProjectTeam, notifyProjectTeam,
@@ -133,7 +133,7 @@ export const projectRouter = router({
} }
// Jury members can only see assigned projects // Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') { if (userHasRole(ctx.user, 'JURY_MEMBER')) {
where.assignments = { where.assignments = {
...((where.assignments as Record<string, unknown>) || {}), ...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id }, some: { userId: ctx.user.id },
@@ -428,7 +428,7 @@ export const projectRouter = router({
} }
// Check access for jury members // Check access for jury members
if (ctx.user.role === 'JURY_MEMBER') { if (userHasRole(ctx.user, 'JURY_MEMBER')) {
const assignment = await ctx.prisma.assignment.findFirst({ const assignment = await ctx.prisma.assignment.findFirst({
where: { where: {
projectId: input.id, projectId: input.id,

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email' import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password' import { hashPassword, validatePassword } from '@/lib/password'
@@ -275,6 +276,7 @@ export const userRouter = router({
email: true, email: true,
name: true, name: true,
role: true, role: true,
roles: true,
status: true, status: true,
expertiseTags: true, expertiseTags: true,
maxAssignments: true, maxAssignments: true,
@@ -929,7 +931,7 @@ export const userRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
role: 'JURY_MEMBER', roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE', status: 'ACTIVE',
} }
@@ -1525,4 +1527,29 @@ export const userRouter = router({
globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [], globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
} }
}), }),
/**
* Update a user's roles array (admin only)
* Also updates the primary role to the highest privilege role in the array.
*/
updateRoles: adminProcedure
.input(z.object({
userId: z.string(),
roles: z.array(z.nativeEnum(UserRole)).min(1),
}))
.mutation(async ({ ctx, input }) => {
// Guard: only SUPER_ADMIN can grant SUPER_ADMIN
if (input.roles.includes('SUPER_ADMIN') && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' })
}
// Set primary role to highest privilege role
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE']
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
return ctx.prisma.user.update({
where: { id: input.userId },
data: { roles: input.roles, role: primaryRole },
})
}),
}) })

View File

@@ -229,7 +229,7 @@ export async function getAIMentorSuggestionsBatch(
where: { where: {
OR: [ OR: [
{ expertiseTags: { isEmpty: false } }, { expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' }, { roles: { has: 'JURY_MEMBER' } },
], ],
status: 'ACTIVE', status: 'ACTIVE',
}, },
@@ -455,7 +455,7 @@ export async function getRoundRobinMentor(
where: { where: {
OR: [ OR: [
{ expertiseTags: { isEmpty: false } }, { expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' }, { roles: { has: 'JURY_MEMBER' } },
], ],
status: 'ACTIVE', status: 'ACTIVE',
id: { notIn: excludeMentorIds }, id: { notIn: excludeMentorIds },

View File

@@ -52,6 +52,15 @@ const isAuthenticated = middleware(async ({ ctx, next }) => {
}) })
}) })
/**
* Helper to check if a user has any of the specified roles.
* Checks the roles array first, falls back to [role] for stale JWT tokens.
*/
export function userHasRole(user: { role: UserRole; roles?: UserRole[] }, ...checkRoles: UserRole[]): boolean {
const userRoles = user.roles?.length ? user.roles : [user.role]
return checkRoles.some(r => userRoles.includes(r))
}
/** /**
* Middleware to require specific role(s) * Middleware to require specific role(s)
*/ */
@@ -64,7 +73,12 @@ const hasRole = (...roles: UserRole[]) =>
}) })
} }
if (!roles.includes(ctx.session.user.role)) { // Use roles array, fallback to [role] for stale JWT tokens
const userRoles = ctx.session.user.roles?.length
? ctx.session.user.roles
: [ctx.session.user.role]
if (!roles.some(r => userRoles.includes(r))) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'You do not have permission to perform this action', message: 'You do not have permission to perform this action',