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
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:
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
const evaluationIdRef = useRef<string | null>(null)
|
||||
const isSubmittedRef = useRef(false)
|
||||
const isSubmittingRef = useRef(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||
|
||||
@@ -318,10 +319,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
autosaveTimerRef.current = null
|
||||
}
|
||||
isSubmittingRef.current = true
|
||||
setIsSubmitting(true)
|
||||
|
||||
if (!myAssignment) {
|
||||
toast.error('Assignment not found')
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -335,16 +338,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||
toast.error(`Please score "${c.label}"`)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (c.type === 'boolean' && val === undefined) {
|
||||
toast.error(`Please answer "${c.label}"`)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
||||
toast.error(`Please fill in "${c.label}"`)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -355,6 +361,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
if (isNaN(score) || score < 1 || score > 10) {
|
||||
toast.error('Please enter a valid score between 1 and 10')
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -363,6 +370,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
if (!binaryDecision) {
|
||||
toast.error('Please select accept or reject')
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -371,6 +379,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
||||
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -414,6 +423,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
} catch {
|
||||
// Error toast already handled by onError callback
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,7 +888,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitMutation.isPending || startMutation.isPending}
|
||||
disabled={submitMutation.isPending || isSubmitting}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -13,7 +13,8 @@ export default async function MentorLayout({
|
||||
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||
|
||||
// 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({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
|
||||
@@ -13,14 +13,44 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 { 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 = {
|
||||
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 [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
@@ -52,12 +82,30 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const hasMembersData = members !== undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
||||
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
@@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !workload || workload.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No assignments yet
|
||||
</p>
|
||||
hasMembersData && members && members.length > 0 ? (
|
||||
// Show members-only view when there are members but no assignments yet
|
||||
<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) => {
|
||||
const pct = juror.completionRate
|
||||
const barGradient = pct === 100
|
||||
@@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||
: 'bg-gray-300'
|
||||
|
||||
// Find the corresponding member entry for cap editing
|
||||
const member = members?.find((m) => m.userId === juror.id)
|
||||
|
||||
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 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">
|
||||
{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">
|
||||
{juror.completed}/{juror.assigned} ({pct}%)
|
||||
</span>
|
||||
@@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { History, ChevronRight } from 'lucide-react'
|
||||
import { History } from 'lucide-react'
|
||||
|
||||
export type ReassignmentHistoryProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: expanded },
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Reassignment History
|
||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||
</CardTitle>
|
||||
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
@@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -364,9 +364,16 @@ export function MembersContent() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
||||
{user.role.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{((user as unknown as { roles?: string[] }).roles?.length
|
||||
? (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>
|
||||
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
||||
@@ -469,9 +476,16 @@ export function MembersContent() {
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
||||
{user.role.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{((user as unknown as { roles?: string[] }).roles?.length
|
||||
? (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 className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
|
||||
@@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
||||
[dist])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Score Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col flex-1 pb-4">
|
||||
{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" />)}
|
||||
</div>
|
||||
) : !dist || dist.totalEvaluations === 0 ? (
|
||||
@@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
||||
No evaluations submitted yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-1 h-32">
|
||||
<div className="flex gap-1 flex-1 min-h-[120px]">
|
||||
{dist.globalDistribution.map((bucket) => {
|
||||
const heightPct = (bucket.count / maxCount) * 100
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
||||
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
export type PipelineRound = {
|
||||
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 }) {
|
||||
const { projectStates } = round
|
||||
|
||||
@@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||
)
|
||||
}
|
||||
|
||||
case '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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'EVALUATION':
|
||||
return <EvaluationRoundContent round={round} />
|
||||
|
||||
case 'SUBMISSION':
|
||||
return (
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
LayoutTemplate,
|
||||
Layers,
|
||||
Scale,
|
||||
Eye,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { EditionSelector } from '@/components/shared/edition-selector'
|
||||
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
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) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { status: sessionStatus } = useSession()
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||
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
|
||||
const dynamicAdminNav = adminNavigation.map((item) => {
|
||||
if (item.name === 'Apply Page' && currentEdition?.id) {
|
||||
@@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
</Link>
|
||||
</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" />
|
||||
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { Route } from 'next'
|
||||
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 { Logo } from '@/components/shared/logo'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
@@ -45,6 +49,15 @@ type RoleNavProps = {
|
||||
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 {
|
||||
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) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { status: sessionStatus } = useSession()
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
const [mounted, setMounted] = useState(false)
|
||||
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 (
|
||||
<header className="sticky top-0 z-40 border-b bg-card">
|
||||
<div className="container-app">
|
||||
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
Settings
|
||||
</Link>
|
||||
</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 />
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
{editionSelector}
|
||||
</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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -19,10 +19,11 @@ export async function requireRole(...allowedRoles: UserRole[]) {
|
||||
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)) {
|
||||
const dashboard = ROLE_DASHBOARDS[userRole]
|
||||
if (!allowedRoles.some(r => userRoles.includes(r))) {
|
||||
const dashboard = ROLE_DASHBOARDS[session.user.role]
|
||||
redirect((dashboard || '/login') as Route)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ declare module 'next-auth' {
|
||||
email: string
|
||||
name?: string | null
|
||||
role: UserRole
|
||||
roles: UserRole[]
|
||||
mustSetPassword?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface User {
|
||||
role?: UserRole
|
||||
roles?: UserRole[]
|
||||
mustSetPassword?: boolean
|
||||
}
|
||||
}
|
||||
@@ -23,6 +25,7 @@ declare module '@auth/core/jwt' {
|
||||
interface JWT {
|
||||
id: string
|
||||
role: UserRole
|
||||
roles?: UserRole[]
|
||||
mustSetPassword?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
status: true,
|
||||
inviteTokenExpiresAt: true,
|
||||
},
|
||||
@@ -95,6 +96,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
roles: user.roles.length ? user.roles : [user.role],
|
||||
mustSetPassword: true,
|
||||
}
|
||||
}
|
||||
@@ -120,6 +122,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
status: true,
|
||||
passwordHash: true,
|
||||
mustSetPassword: true,
|
||||
@@ -183,6 +186,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
roles: user.roles.length ? user.roles : [user.role],
|
||||
mustSetPassword: user.mustSetPassword,
|
||||
}
|
||||
},
|
||||
@@ -195,6 +199,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (user) {
|
||||
token.id = user.id as string
|
||||
token.role = user.role as UserRole
|
||||
token.roles = user.roles?.length ? user.roles : [user.role as UserRole]
|
||||
token.mustSetPassword = user.mustSetPassword
|
||||
}
|
||||
|
||||
@@ -202,10 +207,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (trigger === 'update') {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, mustSetPassword: true },
|
||||
select: { role: true, roles: true, mustSetPassword: true },
|
||||
})
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role
|
||||
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
||||
token.mustSetPassword = dbUser.mustSetPassword
|
||||
}
|
||||
}
|
||||
@@ -216,6 +222,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string
|
||||
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
|
||||
}
|
||||
return session
|
||||
@@ -231,6 +238,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
passwordHash: true,
|
||||
mustSetPassword: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -250,6 +258,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (dbUser) {
|
||||
user.id = dbUser.id
|
||||
user.role = dbUser.role
|
||||
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
||||
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
||||
}
|
||||
}
|
||||
@@ -309,7 +318,9 @@ export async function requireAuth() {
|
||||
// Helper to require specific role(s)
|
||||
export async function requireRole(...roles: UserRole[]) {
|
||||
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')
|
||||
}
|
||||
return session
|
||||
|
||||
@@ -743,7 +743,7 @@ export const analyticsRouter = router({
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
}).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.assignment.count({ where: assignmentFilter }),
|
||||
ctx.prisma.evaluation.findMany({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
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 {
|
||||
generateAIAssignments,
|
||||
@@ -114,7 +114,7 @@ export async function reassignAfterCOI(params: {
|
||||
? await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
@@ -340,7 +340,7 @@ async function reassignDroppedJurorAssignments(params: {
|
||||
? await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
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({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||
},
|
||||
@@ -899,7 +899,7 @@ export const assignmentRouter = router({
|
||||
|
||||
// Verify access
|
||||
if (
|
||||
ctx.user.role === 'JURY_MEMBER' &&
|
||||
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||||
assignment.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -1322,7 +1322,7 @@ export const assignmentRouter = router({
|
||||
|
||||
const jurors = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||
},
|
||||
@@ -2199,7 +2199,7 @@ export const assignmentRouter = router({
|
||||
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
||||
candidateJurors = ids.length > 0
|
||||
? 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 },
|
||||
})
|
||||
: []
|
||||
@@ -2427,7 +2427,7 @@ export const assignmentRouter = router({
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
@@ -2890,7 +2890,7 @@ export const assignmentRouter = router({
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: activeRoundJurorIds },
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||
|
||||
@@ -185,7 +185,7 @@ export const dashboardRouter = router({
|
||||
// 9. Total jurors
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
@@ -194,7 +194,7 @@ export const dashboardRouter = router({
|
||||
// 10. Active jurors
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
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 { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||
import { reassignAfterCOI } from './assignment'
|
||||
@@ -20,7 +20,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
if (
|
||||
ctx.user.role === 'JURY_MEMBER' &&
|
||||
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||||
assignment.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
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 {
|
||||
notifyProjectTeam,
|
||||
@@ -133,7 +133,7 @@ export const projectRouter = router({
|
||||
}
|
||||
|
||||
// Jury members can only see assigned projects
|
||||
if (ctx.user.role === 'JURY_MEMBER') {
|
||||
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||
where.assignments = {
|
||||
...((where.assignments as Record<string, unknown>) || {}),
|
||||
some: { userId: ctx.user.id },
|
||||
@@ -428,7 +428,7 @@ export const projectRouter = router({
|
||||
}
|
||||
|
||||
// Check access for jury members
|
||||
if (ctx.user.role === 'JURY_MEMBER') {
|
||||
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||
const assignment = await ctx.prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId: input.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { UserRole } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
||||
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
||||
import { hashPassword, validatePassword } from '@/lib/password'
|
||||
@@ -275,6 +276,7 @@ export const userRouter = router({
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
status: true,
|
||||
expertiseTags: true,
|
||||
maxAssignments: true,
|
||||
@@ -929,7 +931,7 @@ export const userRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
role: 'JURY_MEMBER',
|
||||
roles: { has: 'JURY_MEMBER' },
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
@@ -1525,4 +1527,29 @@ export const userRouter = router({
|
||||
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 },
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function getAIMentorSuggestionsBatch(
|
||||
where: {
|
||||
OR: [
|
||||
{ expertiseTags: { isEmpty: false } },
|
||||
{ role: 'JURY_MEMBER' },
|
||||
{ roles: { has: 'JURY_MEMBER' } },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
@@ -455,7 +455,7 @@ export async function getRoundRobinMentor(
|
||||
where: {
|
||||
OR: [
|
||||
{ expertiseTags: { isEmpty: false } },
|
||||
{ role: 'JURY_MEMBER' },
|
||||
{ roles: { has: 'JURY_MEMBER' } },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
id: { notIn: excludeMentorIds },
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
@@ -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({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have permission to perform this action',
|
||||
|
||||
Reference in New Issue
Block a user