Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes

- Round detail: add skeleton loading for filtering stats, inline results table
  with expandable rows, pagination, override/reinstate, CSV export, and tooltip
  on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
  flags with tooltip instead of country codes (table + card views), add listAllIds
  backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
  tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
  role hierarchy (only super admins can modify admin/super-admin roles) in both
  backend and UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-10 23:07:38 +01:00
parent 5cae78fe0c
commit 5c8d22ac11
9 changed files with 1257 additions and 197 deletions

View File

@ -74,7 +74,7 @@ export default function MemberDetailPage() {
const [name, setName] = useState('')
const [role, setRole] = useState<string>('JURY_MEMBER')
const [status, setStatus] = useState<string>('INVITED')
const [status, setStatus] = useState<string>('NONE')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
@ -96,7 +96,7 @@ export default function MemberDetailPage() {
id: userId,
name: name || null,
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED',
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
@ -180,11 +180,11 @@ export default function MemberDetailPage() {
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{user.email}</p>
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
{user.status}
{user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
</div>
</div>
{user.status === 'INVITED' && (
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
onClick={handleSendInvitation}
@ -235,6 +235,7 @@ export default function MemberDetailPage() {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
@ -243,7 +244,9 @@ export default function MemberDetailPage() {
{isSuperAdmin && (
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
)}
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
)}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
@ -257,6 +260,7 @@ export default function MemberDetailPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
@ -379,6 +383,16 @@ export default function MemberDetailPage() {
<UserActivityLog userId={userId} />
{/* Status Alert */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added to the platform via project import but hasn&apos;t been
invited yet. Send them an invitation using the button above.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />

View File

@ -82,10 +82,17 @@ import {
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
import { StatusBadge } from '@/components/shared/status-badge'
import { Pagination } from '@/components/shared/pagination'
import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries'
import {
ProjectFiltersBar,
type ProjectFilters,
@ -375,6 +382,7 @@ export default function ProjectsPage() {
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
@ -382,10 +390,52 @@ export default function ProjectsPage() {
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
// Query for fetching all matching IDs (used for "select all across pages")
const allIdsQuery = trpc.project.listAllIds.useQuery(
{
search: filters.search || undefined,
statuses:
filters.statuses.length > 0
? (filters.statuses as Array<
| 'SUBMITTED'
| 'ELIGIBLE'
| 'ASSIGNED'
| 'SEMIFINALIST'
| 'FINALIST'
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
oceanIssue: filters.oceanIssue
? (filters.oceanIssue as
| 'POLLUTION_REDUCTION'
| 'CLIMATE_MITIGATION'
| 'TECHNOLOGY_INNOVATION'
| 'SUSTAINABLE_SHIPPING'
| 'BLUE_CARBON'
| 'HABITAT_RESTORATION'
| 'COMMUNITY_CAPACITY'
| 'SUSTAINABLE_FISHING'
| 'CONSUMER_AWARENESS'
| 'OCEAN_ACIDIFICATION'
| 'OTHER')
: undefined,
country: filters.country || undefined,
wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments,
},
{ enabled: false } // Only fetch on demand
)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkStatus('')
setBulkConfirmOpen(false)
utils.project.list.invalidate()
@ -399,6 +449,7 @@ export default function ProjectsPage() {
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
@ -412,6 +463,7 @@ export default function ProjectsPage() {
onSuccess: (result) => {
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkDeleteConfirmOpen(false)
utils.project.list.invalidate()
},
@ -421,6 +473,7 @@ export default function ProjectsPage() {
})
const handleToggleSelect = (id: string) => {
setAllMatchingSelected(false)
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
@ -434,6 +487,7 @@ export default function ProjectsPage() {
const handleSelectAll = () => {
if (!data) return
setAllMatchingSelected(false)
const allVisible = data.projects.map((p) => p.id)
const allSelected = allVisible.every((id) => selectedIds.has(id))
if (allSelected) {
@ -451,6 +505,20 @@ export default function ProjectsPage() {
}
}
const handleSelectAllMatching = async () => {
const result = await allIdsQuery.refetch()
if (result.data) {
setSelectedIds(new Set(result.data.ids))
setAllMatchingSelected(true)
}
}
const handleClearSelection = () => {
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkStatus('')
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkConfirmOpen(true)
@ -620,6 +688,47 @@ export default function ProjectsPage() {
</div>
)}
{/* Select All Banner */}
{data && allVisibleSelected && data.total > data.projects.length && !allMatchingSelected && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
<span>
All <strong>{data.projects.length}</strong> projects on this page are selected.
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0 font-semibold"
onClick={handleSelectAllMatching}
disabled={allIdsQuery.isFetching}
>
{allIdsQuery.isFetching ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Loading...
</>
) : (
`Select all ${data.total} matching projects`
)}
</Button>
</div>
)}
{allMatchingSelected && data && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
<CheckCircle2 className="h-4 w-4 text-primary" />
<span>
All <strong>{selectedIds.size}</strong> matching projects are selected.
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={handleClearSelection}
>
Clear selection
</Button>
</div>
)}
{/* Content */}
{isLoading ? (
<Card>
@ -728,9 +837,23 @@ export default function ProjectsPage() {
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
{project.country && (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
)
})()}
</p>
</div>
</Link>
@ -983,9 +1106,23 @@ export default function ProjectsPage() {
</div>
<CardDescription className="mt-0.5">
{project.teamName}
{project.country && (
{project.country && (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
)
})()}
</CardDescription>
</div>
</div>
@ -1118,10 +1255,7 @@ export default function ProjectsPage() {
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
onClick={handleClearSelection}
className="shrink-0"
>
<X className="mr-1 h-4 w-4" />

View File

@ -1,6 +1,6 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
@ -16,6 +16,31 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
@ -27,6 +52,12 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ArrowLeft,
Edit,
@ -52,13 +83,39 @@ import {
ClipboardCheck,
Sparkles,
LayoutTemplate,
ShieldCheck,
Download,
RotateCcw,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
import { Pagination } from '@/components/shared/pagination'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { format, formatDistanceToNow, isFuture } from 'date-fns'
const OUTCOME_BADGES: Record<
string,
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
> = {
PASSED: {
variant: 'default',
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
label: 'Passed',
},
FILTERED_OUT: {
variant: 'destructive',
icon: <XCircle className="mr-1 h-3 w-3" />,
label: 'Filtered Out',
},
FLAGGED: {
variant: 'secondary',
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
label: 'Flagged',
},
}
interface PageProps {
params: Promise<{ id: string }>
}
@ -70,6 +127,18 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [removeOpen, setRemoveOpen] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
// Inline filtering results state
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
const [resultsPage, setResultsPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [overrideDialog, setOverrideDialog] = useState<{
id: string
currentOutcome: string
} | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
const [overrideReason, setOverrideReason] = useState('')
const [showExportDialog, setShowExportDialog] = useState(false)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
@ -77,10 +146,10 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const isFilteringRound = round?.roundType === 'FILTERING'
// Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, refetch: refetchFilteringStats } =
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound }
{ enabled: isFilteringRound, staleTime: 0 }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
@ -93,7 +162,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ enabled: isFilteringRound }
{ enabled: isFilteringRound, staleTime: 0 }
)
// Poll for job status when there's an active job
@ -102,6 +171,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
staleTime: 0,
}
)
@ -134,6 +204,30 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
},
})
// Inline filtering results
const resultsPerPage = 20
const { data: filteringResults, refetch: refetchResults } =
trpc.filtering.getResults.useQuery(
{
roundId,
outcome: outcomeFilter
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
: undefined,
page: resultsPage,
perPage: resultsPerPage,
},
{
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
staleTime: 0,
}
)
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
// Save as template
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
onSuccess: (data) => {
@ -180,13 +274,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)
setActiveJobId(null)
refetchFilteringStats()
refetchResults()
refetchLatestJob()
} else if (jobStatus?.status === 'FAILED') {
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
setActiveJobId(null)
refetchLatestJob()
}
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchResults, refetchLatestJob])
const handleStartFiltering = async () => {
try {
@ -224,6 +319,50 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
}
}
// Inline results handlers
const toggleResultRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedRows(next)
}
const handleOverride = async () => {
if (!overrideDialog || !overrideReason.trim()) return
try {
await overrideResult.mutateAsync({
id: overrideDialog.id,
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
reason: overrideReason.trim(),
})
toast.success('Result overridden')
setOverrideDialog(null)
setOverrideReason('')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
}
const handleReinstate = async (projectId: string) => {
try {
await reinstateProject.mutateAsync({ roundId, projectId })
toast.success('Project reinstated')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
}
const handleRequestExportData = useCallback(async () => {
const result = await exportResults.refetch()
return result.data ?? undefined
}, [exportResults])
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
const progressPercent = jobStatus?.totalBatches
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
@ -666,7 +805,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
{/* Stats */}
{filteringStats && filteringStats.total > 0 ? (
{isLoadingFilteringStats && !isJobRunning ? (
<div className="grid gap-4 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-7 w-12" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
) : filteringStats && filteringStats.total > 0 ? (
<div className="grid gap-4 sm:grid-cols-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
@ -721,6 +872,332 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
)}
{/* Inline Filtering Results Table */}
{filteringStats && filteringStats.total > 0 && (
<>
<Separator />
<div className="space-y-4">
{/* Outcome Filter Tabs */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
<Button
key={outcome || 'all'}
variant={outcomeFilter === outcome ? 'default' : 'outline'}
size="sm"
onClick={() => {
setOutcomeFilter(outcome)
setResultsPage(1)
}}
>
{outcome ? (
<>
{OUTCOME_BADGES[outcome].icon}
{OUTCOME_BADGES[outcome].label}
</>
) : (
'All'
)}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowExportDialog(true)}
disabled={exportResults.isFetching}
>
{exportResults.isFetching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
</div>
{/* Results Table */}
{filteringResults && filteringResults.results.length > 0 ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Outcome</TableHead>
<TableHead className="w-[300px]">AI Reason</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteringResults.results.map((result) => {
const isExpanded = expandedRows.has(result.id)
const effectiveOutcome =
result.finalOutcome || result.outcome
const badge = OUTCOME_BADGES[effectiveOutcome]
const aiScreening = result.aiScreeningJson as Record<string, {
meetsCriteria?: boolean
confidence?: number
reasoning?: string
qualityScore?: number
spamRisk?: boolean
}> | null
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
const aiReasoning = firstAiResult?.reasoning
return (
<>
<TableRow
key={result.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleResultRow(result.id)}
>
<TableCell>
<div>
<p className="font-medium">
{result.project.title}
</p>
<p className="text-sm text-muted-foreground">
{result.project.teamName}
{result.project.country && ` · ${result.project.country}`}
</p>
</div>
</TableCell>
<TableCell>
{result.project.competitionCategory ? (
<Badge variant="outline">
{result.project.competitionCategory.replace(
'_',
' '
)}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={badge?.variant || 'secondary'}>
{badge?.icon}
{badge?.label || effectiveOutcome}
</Badge>
{result.overriddenByUser && (
<p className="text-xs text-muted-foreground">
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
</p>
)}
</div>
</TableCell>
<TableCell>
{aiReasoning ? (
<div className="space-y-1">
<p className="text-sm line-clamp-2">
{aiReasoning}
</p>
{firstAiResult && (
<div className="flex gap-2 text-xs text-muted-foreground">
{firstAiResult.confidence !== undefined && (
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
)}
{firstAiResult.qualityScore !== undefined && (
<span>Quality: {firstAiResult.qualityScore}/10</span>
)}
{firstAiResult.spamRisk && (
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
)}
</div>
)}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
No AI screening
</span>
)}
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOutcome('PASSED')
setOverrideDialog({
id: result.id,
currentOutcome: effectiveOutcome,
})
}}
>
<ShieldCheck className="mr-1 h-3 w-3" />
Override
</Button>
{effectiveOutcome === 'FILTERED_OUT' && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleReinstate(result.projectId)
}
disabled={reinstateProject.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reinstate
</Button>
)}
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${result.id}-detail`}>
<TableCell colSpan={5} className="bg-muted/30">
<div className="p-4 space-y-4">
{/* Rule Results */}
<div>
<p className="text-sm font-medium mb-2">
Rule Results
</p>
{result.ruleResultsJson &&
Array.isArray(result.ruleResultsJson) ? (
<div className="space-y-2">
{(
result.ruleResultsJson as Array<{
ruleName: string
ruleType: string
passed: boolean
action: string
reasoning?: string
}>
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
<div
key={i}
className="flex items-start gap-2 text-sm"
>
{rr.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
)}
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{rr.ruleName}
</span>
<Badge variant="outline" className="text-xs">
{rr.ruleType.replace('_', ' ')}
</Badge>
</div>
{rr.reasoning && (
<p className="text-muted-foreground mt-0.5">
{rr.reasoning}
</p>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No detailed rule results available
</p>
)}
</div>
{/* AI Screening Details */}
{aiScreening && Object.keys(aiScreening).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">
AI Screening Analysis
</p>
<div className="space-y-3">
{Object.entries(aiScreening).map(([ruleId, screening]) => (
<div key={ruleId} className="p-3 bg-background rounded-lg border">
<div className="flex items-center gap-2 mb-2">
{screening.meetsCriteria ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="font-medium text-sm">
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
</span>
{screening.spamRisk && (
<Badge variant="destructive" className="text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
Spam Risk
</Badge>
)}
</div>
{screening.reasoning && (
<p className="text-sm text-muted-foreground mb-2">
{screening.reasoning}
</p>
)}
<div className="flex gap-4 text-xs text-muted-foreground">
{screening.confidence !== undefined && (
<span>
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
</span>
)}
{screening.qualityScore !== undefined && (
<span>
Quality Score: <strong>{screening.qualityScore}/10</strong>
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Override Info */}
{result.overriddenByUser && (
<div className="pt-3 border-t">
<p className="text-sm font-medium mb-1">Manual Override</p>
<p className="text-sm text-muted-foreground">
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
{result.overriddenByUser.name || result.overriddenByUser.email}
</p>
{result.overrideReason && (
<p className="text-sm text-muted-foreground mt-1">
Reason: {result.overrideReason}
</p>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</div>
<Pagination
page={filteringResults.page}
totalPages={filteringResults.totalPages}
total={filteringResults.total}
perPage={resultsPerPage}
onPageChange={setResultsPage}
/>
</>
) : filteringResults ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium">No results match this filter</p>
</div>
) : null}
</div>
</>
)}
{/* Quick links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
@ -732,12 +1209,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</Badge>
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/results`}>
<ClipboardCheck className="mr-2 h-4 w-4" />
Review Results
</Link>
</Button>
{filteringStats && filteringStats.total > 0 && (
<Button
onClick={handleFinalizeFiltering}
@ -804,6 +1275,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
@ -817,6 +1291,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
@ -861,6 +1341,82 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
onOpenChange={setRemoveOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
{/* Override Dialog */}
<Dialog
open={!!overrideDialog}
onOpenChange={(open) => {
if (!open) {
setOverrideDialog(null)
setOverrideReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Override Filtering Result</DialogTitle>
<DialogDescription>
Change the outcome for this project. This will be logged in the
audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New Outcome</Label>
<Select
value={overrideOutcome}
onValueChange={setOverrideOutcome}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Input
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="Explain why you're overriding..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOverrideDialog(null)}
>
Cancel
</Button>
<Button
onClick={handleOverride}
disabled={
overrideResult.isPending || !overrideReason.trim()
}
>
{overrideResult.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Override
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* CSV Export Dialog */}
<CsvExportDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
exportData={exportResults.data ?? undefined}
isLoading={exportResults.isFetching}
filename="filtering-results"
onRequestData={handleRequestExportData}
/>
</div>
)
}

View File

@ -8,15 +8,22 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
async function SettingsLoader() {
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({
orderBy: [{ category: 'asc' }, { key: 'asc' }],
})
// Convert settings array to key-value map
// For secrets, pass a marker but not the actual value
// For non-super-admins, filter out infrastructure categories
const settingsMap: Record<string, string> = {}
settings.forEach((setting) => {
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
return
}
if (setting.isSecret && setting.value) {
// Pass marker for UI to show "existing" state
settingsMap[setting.key] = '********'
@ -25,7 +32,7 @@ async function SettingsLoader() {
}
})
return <SettingsContent initialSettings={settingsMap} />
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
}
function SettingsSkeleton() {
@ -52,11 +59,13 @@ function SettingsSkeleton() {
export default async function SettingsPage() {
const session = await auth()
// Only super admins can access settings
if (session?.user?.role !== 'SUPER_ADMIN') {
// Only admins (super admin + program admin) can access settings
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
redirect('/admin')
}
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
return (
<div className="space-y-6">
{/* Header */}
@ -69,7 +78,7 @@ export default async function SettingsPage() {
{/* Content */}
<Suspense fallback={<SettingsSkeleton />}>
<SettingsLoader />
<SettingsLoader isSuperAdmin={isSuperAdmin} />
</Suspense>
</div>
)

View File

@ -42,11 +42,16 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
NONE: 'secondary',
ACTIVE: 'success',
INVITED: 'secondary',
SUSPENDED: 'destructive',
}
const statusLabels: Record<string, string> = {
NONE: 'Not Invited',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
MENTOR: 'secondary',
@ -92,6 +97,9 @@ export function MembersContent() {
const roles = TAB_ROLES[tab]
const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined
const { data, isLoading } = trpc.user.list.useQuery({
roles: roles,
search: search || undefined,
@ -216,7 +224,7 @@ export function MembersContent() {
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
{statusLabels[user.status] || user.status}
</Badge>
</TableCell>
<TableCell>
@ -233,6 +241,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
currentUserRole={currentUserRole}
/>
</TableCell>
</TableRow>
@ -263,7 +273,7 @@ export function MembersContent() {
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
{statusLabels[user.status] || user.status}
</Badge>
</div>
</CardHeader>
@ -305,6 +315,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
currentUserRole={currentUserRole}
/>
</CardContent>
</Card>

View File

@ -9,6 +9,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@ -28,15 +31,29 @@ import {
UserCog,
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
}
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
@ -44,13 +61,40 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
// Determine which roles can be assigned
const getAvailableRoles = (): Role[] => {
if (isSuperAdmin) {
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Program admins can only assign lower roles
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
}
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
@ -98,9 +142,31 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
Edit
</Link>
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
@ -147,18 +213,35 @@ interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
userRole,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
@ -175,7 +258,8 @@ export function UserMobileActions({
}
return (
<div className="flex gap-2 pt-2">
<div className="space-y-2 pt-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
@ -187,7 +271,7 @@ export function UserMobileActions({
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -197,5 +281,23 @@ export function UserMobileActions({
Invite
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
)}
</div>
)
}

View File

@ -61,9 +61,10 @@ function SettingsSkeleton() {
interface SettingsContentProps {
initialSettings: Record<string, string>
isSuperAdmin?: boolean
}
export function SettingsContent({ initialSettings }: SettingsContentProps) {
export function SettingsContent({ initialSettings, isSuperAdmin = true }: SettingsContentProps) {
// We use the initial settings passed from the server
// Forms will refetch on mutation success
@ -168,10 +169,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
)}
<TabsTrigger value="notifications" className="gap-2 shrink-0">
<Bell className="h-4 w-4" />
Notif.
@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
)}
<TabsTrigger value="audit" className="gap-2 shrink-0">
<ShieldAlert className="h-4 w-4" />
Audit
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="ai" className="gap-2 shrink-0">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="gap-2 shrink-0">
<Tags className="h-4 w-4" />
Tags
@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<BarChart3 className="h-4 w-4" />
Analytics
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="storage" className="gap-2 shrink-0">
<HardDrive className="h-4 w-4" />
Storage
</TabsTrigger>
)}
</TabsList>
<div className="lg:flex lg:gap-8">
@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
{isSuperAdmin && (
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
)}
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bell className="h-4 w-4" />
Notifications
@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
{isSuperAdmin && (
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
)}
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<ShieldAlert className="h-4 w-4" />
Audit
@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
{isSuperAdmin && (
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Tags className="h-4 w-4" />
Tags
@ -274,6 +289,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</TabsTrigger>
</TabsList>
</div>
{isSuperAdmin && (
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
@ -283,12 +299,14 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</TabsTrigger>
</TabsList>
</div>
)}
</nav>
</div>
{/* Content area */}
<div className="flex-1 min-w-0">
{isSuperAdmin && (
<TabsContent value="ai" className="space-y-6">
<Card>
<CardHeader>
@ -303,6 +321,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
<AIUsageCard />
</TabsContent>
)}
<TabsContent value="tags">
<Card>
@ -350,6 +369,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="email">
<Card>
<CardHeader>
@ -363,6 +383,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="notifications">
<Card>
@ -378,6 +399,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="storage">
<Card>
<CardHeader>
@ -391,7 +413,9 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</CardContent>
</Card>
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="security">
<Card>
<CardHeader>
@ -405,6 +429,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="defaults">
<Card>
@ -502,6 +527,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</CardContent>
</Card>
{isSuperAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
@ -522,6 +548,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Button>
</CardContent>
</Card>
)}
</div>
</>
)

View File

@ -1,3 +1,4 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
@ -9,6 +10,9 @@ import {
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
// Valid project status transitions
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
@ -81,17 +85,23 @@ export const projectRouter = router({
// Build where clause
const where: Record<string, unknown> = {}
// Filter by program via round
if (programId) where.round = { programId }
// Filter by program
if (programId) where.programId = programId
// Filter by round
if (roundId) {
where.roundId = roundId
}
// Exclude projects in a specific round
// Exclude projects in a specific round (include unassigned projects with roundId=null)
if (notInRoundId) {
where.roundId = { not: notInRoundId }
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
}
// Filter by unassigned (no round)
@ -164,6 +174,91 @@ export const projectRouter = router({
}
}),
/**
* List all project IDs matching filters (no pagination).
* Used for "select all across pages" in bulk operations.
*/
listAllIds: adminProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
notInRoundId: z.string().optional(),
unassignedOnly: z.boolean().optional(),
search: z.string().optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, notInRoundId, unassignedOnly,
search, statuses, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
} = input
const where: Record<string, unknown> = {}
if (programId) where.programId = programId
if (roundId) where.roundId = roundId
if (notInRoundId) {
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
}
if (unassignedOnly) where.roundId = null
if (statuses?.length) where.status = { in: statuses }
if (tags && tags.length > 0) where.tags = { hasSome: tags }
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
const projects = await ctx.prisma.project.findMany({
where,
select: { id: true },
orderBy: { createdAt: 'desc' },
})
return { ids: projects.map((p) => p.id) }
}),
/**
* Get filter options for the project list (distinct values)
*/
@ -318,12 +413,21 @@ export const projectRouter = router({
contactName: z.string().optional(),
city: z.string().optional(),
metadataJson: z.record(z.unknown()).optional(),
teamMembers: z.array(z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
title: z.string().optional(),
phone: z.string().optional(),
sendInvite: z.boolean().default(false),
})).max(10).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const {
metadataJson,
contactPhone, contactEmail, contactName, city,
teamMembers: teamMembersInput,
...rest
} = input
@ -349,7 +453,7 @@ export const projectRouter = router({
? normalizeCountryToCode(input.country)
: undefined
const project = await ctx.prisma.$transaction(async (tx) => {
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
programId: resolvedProgramId,
@ -369,20 +473,112 @@ export const projectRouter = router({
},
})
// Create team members if provided
const inviteList: { userId: string; email: string; name: string }[] = []
if (teamMembersInput && teamMembersInput.length > 0) {
for (const member of teamMembersInput) {
// Find or create user
let user = await tx.user.findUnique({
where: { email: member.email.toLowerCase() },
select: { id: true, status: true },
})
if (!user) {
user = await tx.user.create({
data: {
email: member.email.toLowerCase(),
name: member.name,
role: 'APPLICANT',
status: 'NONE',
phoneNumber: member.phone || null,
},
select: { id: true, status: true },
})
}
// Create TeamMember link (skip if already linked)
await tx.teamMember.upsert({
where: {
projectId_userId: {
projectId: created.id,
userId: user.id,
},
},
create: {
projectId: created.id,
userId: user.id,
role: member.role,
title: member.title || null,
},
update: {
role: member.role,
title: member.title || null,
},
})
if (member.sendInvite) {
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
}
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId },
detailsJson: {
title: input.title,
roundId: input.roundId,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
return { project: created, membersToInvite: inviteList }
})
// Send invite emails outside the transaction (never fail project creation)
if (membersToInvite.length > 0) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
for (const member of membersToInvite) {
try {
const token = crypto.randomBytes(32).toString('hex')
await ctx.prisma.user.update({
where: { id: member.userId },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/auth/accept-invite?token=${token}`
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
// Log notification
try {
await ctx.prisma.notificationLog.create({
data: {
userId: member.userId,
channel: 'EMAIL',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
} catch {
// Never fail on notification logging
}
} catch {
// Email sending failure should not break project creation
console.error(`Failed to send invite to ${member.email}`)
}
}
}
return project
}),

View File

@ -185,7 +185,7 @@ export const userRouter = router({
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
@ -340,7 +340,7 @@ export const userRouter = router({
id: z.string(),
name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
availabilityJson: z.any().optional(),
@ -362,6 +362,14 @@ export const userRouter = router({
})
}
// Prevent non-super-admins from changing admin roles
if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can change admin roles',
})
}
// Prevent non-super-admins from assigning super admin or admin role
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
@ -708,18 +716,19 @@ export const userRouter = router({
where: { id: input.userId },
})
if (user.status !== 'INVITED') {
if (user.status !== 'NONE' && user.status !== 'INVITED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'User has already accepted their invitation',
})
}
// Generate invite token and store on user
// Generate invite token, set status to INVITED, and store on user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
@ -766,7 +775,7 @@ export const userRouter = router({
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
status: 'INVITED',
status: { in: ['NONE', 'INVITED'] },
},
})
@ -780,11 +789,12 @@ export const userRouter = router({
for (const user of users) {
try {
// Generate invite token for each user
// Generate invite token for each user and set status to INVITED
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},