From 5c8d22ac113bdea07cd5b9826c4c1f5c1f65d551 Mon Sep 17 00:00:00 2001
From: Matt
Date: Tue, 10 Feb 2026 23:07:38 +0100
Subject: [PATCH] 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
---
src/app/(admin)/admin/members/[id]/page.tsx | 24 +-
src/app/(admin)/admin/projects/page.tsx | 154 ++++-
src/app/(admin)/admin/rounds/[id]/page.tsx | 606 ++++++++++++++++++-
src/app/(admin)/admin/settings/page.tsx | 19 +-
src/components/admin/members-content.tsx | 16 +-
src/components/admin/user-actions.tsx | 154 ++++-
src/components/settings/settings-content.tsx | 249 ++++----
src/server/routers/project.ts | 210 ++++++-
src/server/routers/user.ts | 22 +-
9 files changed, 1257 insertions(+), 197 deletions(-)
diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx
index 326096e..e1a3691 100644
--- a/src/app/(admin)/admin/members/[id]/page.tsx
+++ b/src/app/(admin)/admin/members/[id]/page.tsx
@@ -74,7 +74,7 @@ export default function MemberDetailPage() {
const [name, setName] = useState('')
const [role, setRole] = useState('JURY_MEMBER')
- const [status, setStatus] = useState('INVITED')
+ const [status, setStatus] = useState('NONE')
const [expertiseTags, setExpertiseTags] = useState([])
const [maxAssignments, setMaxAssignments] = useState('')
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() {
{user.email}
- {user.status}
+ {user.status === 'NONE' ? 'Not Invited' : user.status}
- {user.status === 'INVITED' && (
+ {(user.status === 'NONE' || user.status === 'INVITED') && (
@@ -243,7 +244,9 @@ export default function MemberDetailPage() {
{isSuperAdmin && (
Super Admin
)}
- Program Admin
+ {isSuperAdmin && (
+ Program Admin
+ )}
Jury Member
Mentor
Observer
@@ -257,6 +260,7 @@ export default function MemberDetailPage() {
+ Not Invited
Invited
Active
Suspended
@@ -379,6 +383,16 @@ export default function MemberDetailPage() {
{/* Status Alert */}
+ {user.status === 'NONE' && (
+
+
+ Not Yet Invited
+
+ This member was added to the platform via project import but hasn't been
+ invited yet. Send them an invitation using the button above.
+
+
+ )}
{user.status === 'INVITED' && (
diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx
index c509c10..f93d4c3 100644
--- a/src/app/(admin)/admin/projects/page.tsx
+++ b/src/app/(admin)/admin/projects/page.tsx
@@ -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>(new Set())
+ const [allMatchingSelected, setAllMatchingSelected] = useState(false)
const [bulkStatus, setBulkStatus] = useState('')
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() {
)}
+ {/* Select All Banner */}
+ {data && allVisibleSelected && data.total > data.projects.length && !allMatchingSelected && (
+
+
+ All {data.projects.length} projects on this page are selected.
+
+
+ {allIdsQuery.isFetching ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ `Select all ${data.total} matching projects`
+ )}
+
+
+ )}
+ {allMatchingSelected && data && (
+
+
+
+ All {selectedIds.size} matching projects are selected.
+
+
+ Clear selection
+
+
+ )}
+
{/* Content */}
{isLoading ? (
@@ -728,9 +837,23 @@ export default function ProjectsPage() {
{project.teamName}
- {project.country && (
- · {project.country}
- )}
+ {project.country && (() => {
+ const code = normalizeCountryToCode(project.country)
+ const flag = code ? getCountryFlag(code) : null
+ const name = code ? getCountryName(code) : project.country
+ return flag ? (
+
+
+
+ · {flag}
+
+ {name}
+
+
+ ) : (
+ · {project.country}
+ )
+ })()}
@@ -983,9 +1106,23 @@ export default function ProjectsPage() {
{project.teamName}
- {project.country && (
- · {project.country}
- )}
+ {project.country && (() => {
+ const code = normalizeCountryToCode(project.country)
+ const flag = code ? getCountryFlag(code) : null
+ const name = code ? getCountryName(code) : project.country
+ return flag ? (
+
+
+
+ · {flag}
+
+ {name}
+
+
+ ) : (
+ · {project.country}
+ )
+ })()}
@@ -1118,10 +1255,7 @@ export default function ProjectsPage() {
{
- setSelectedIds(new Set())
- setBulkStatus('')
- }}
+ onClick={handleClearSelection}
className="shrink-0"
>
diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx
index 58a9e69..2981942 100644
--- a/src/app/(admin)/admin/rounds/[id]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/page.tsx
@@ -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: ,
+ label: 'Passed',
+ },
+ FILTERED_OUT: {
+ variant: 'destructive',
+ icon: ,
+ label: 'Filtered Out',
+ },
+ FLAGGED: {
+ variant: 'secondary',
+ icon: ,
+ 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(null)
+ // Inline filtering results state
+ const [outcomeFilter, setOutcomeFilter] = useState('')
+ const [resultsPage, setResultsPage] = useState(1)
+ const [expandedRows, setExpandedRows] = useState>(new Set())
+ const [overrideDialog, setOverrideDialog] = useState<{
+ id: string
+ currentOutcome: string
+ } | null>(null)
+ const [overrideOutcome, setOverrideOutcome] = useState('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 ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : filteringStats && filteringStats.total > 0 ? (
@@ -721,6 +872,332 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
+ {/* Inline Filtering Results Table */}
+ {filteringStats && filteringStats.total > 0 && (
+ <>
+
+
+ {/* Outcome Filter Tabs */}
+
+
+ {['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
+ {
+ setOutcomeFilter(outcome)
+ setResultsPage(1)
+ }}
+ >
+ {outcome ? (
+ <>
+ {OUTCOME_BADGES[outcome].icon}
+ {OUTCOME_BADGES[outcome].label}
+ >
+ ) : (
+ 'All'
+ )}
+
+ ))}
+
+
setShowExportDialog(true)}
+ disabled={exportResults.isFetching}
+ >
+ {exportResults.isFetching ? (
+
+ ) : (
+
+ )}
+ Export CSV
+
+
+
+ {/* Results Table */}
+ {filteringResults && filteringResults.results.length > 0 ? (
+ <>
+
+
+
+
+ Project
+ Category
+ Outcome
+ AI Reason
+ Actions
+
+
+
+ {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 | null
+ const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
+ const aiReasoning = firstAiResult?.reasoning
+
+ return (
+ <>
+ toggleResultRow(result.id)}
+ >
+
+
+
+ {result.project.title}
+
+
+ {result.project.teamName}
+ {result.project.country && ` · ${result.project.country}`}
+
+
+
+
+ {result.project.competitionCategory ? (
+
+ {result.project.competitionCategory.replace(
+ '_',
+ ' '
+ )}
+
+ ) : (
+ '-'
+ )}
+
+
+
+
+ {badge?.icon}
+ {badge?.label || effectiveOutcome}
+
+ {result.overriddenByUser && (
+
+ Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
+
+ )}
+
+
+
+ {aiReasoning ? (
+
+
+ {aiReasoning}
+
+ {firstAiResult && (
+
+ {firstAiResult.confidence !== undefined && (
+ Confidence: {Math.round(firstAiResult.confidence * 100)}%
+ )}
+ {firstAiResult.qualityScore !== undefined && (
+ Quality: {firstAiResult.qualityScore}/10
+ )}
+ {firstAiResult.spamRisk && (
+ Spam Risk
+ )}
+
+ )}
+
+ ) : (
+
+ No AI screening
+
+ )}
+
+
+ e.stopPropagation()}
+ >
+ {
+ setOverrideOutcome('PASSED')
+ setOverrideDialog({
+ id: result.id,
+ currentOutcome: effectiveOutcome,
+ })
+ }}
+ >
+
+ Override
+
+ {effectiveOutcome === 'FILTERED_OUT' && (
+
+ handleReinstate(result.projectId)
+ }
+ disabled={reinstateProject.isPending}
+ >
+
+ Reinstate
+
+ )}
+
+
+
+ {isExpanded && (
+
+
+
+ {/* Rule Results */}
+
+
+ Rule Results
+
+ {result.ruleResultsJson &&
+ Array.isArray(result.ruleResultsJson) ? (
+
+ {(
+ result.ruleResultsJson as Array<{
+ ruleName: string
+ ruleType: string
+ passed: boolean
+ action: string
+ reasoning?: string
+ }>
+ ).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
+
+ {rr.passed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {rr.ruleName}
+
+
+ {rr.ruleType.replace('_', ' ')}
+
+
+ {rr.reasoning && (
+
+ {rr.reasoning}
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ No detailed rule results available
+
+ )}
+
+
+ {/* AI Screening Details */}
+ {aiScreening && Object.keys(aiScreening).length > 0 && (
+
+
+ AI Screening Analysis
+
+
+ {Object.entries(aiScreening).map(([ruleId, screening]) => (
+
+
+ {screening.meetsCriteria ? (
+
+ ) : (
+
+ )}
+
+ {screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
+
+ {screening.spamRisk && (
+
+
+ Spam Risk
+
+ )}
+
+ {screening.reasoning && (
+
+ {screening.reasoning}
+
+ )}
+
+ {screening.confidence !== undefined && (
+
+ Confidence: {Math.round(screening.confidence * 100)}%
+
+ )}
+ {screening.qualityScore !== undefined && (
+
+ Quality Score: {screening.qualityScore}/10
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Override Info */}
+ {result.overriddenByUser && (
+
+
Manual Override
+
+ Overridden to {result.finalOutcome} by{' '}
+ {result.overriddenByUser.name || result.overriddenByUser.email}
+
+ {result.overrideReason && (
+
+ Reason: {result.overrideReason}
+
+ )}
+
+ )}
+
+
+
+ )}
+ >
+ )
+ })}
+
+
+
+
+
+ >
+ ) : filteringResults ? (
+
+
+
No results match this filter
+
+ ) : null}
+
+ >
+ )}
+
{/* Quick links */}
@@ -732,12 +1209,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
-
-
-
- Review Results
-
-
{filteringStats && filteringStats.total > 0 && (
-
bulkSummaries.mutate({ roundId: round.id })}
- disabled={bulkSummaries.isPending}
- >
- {bulkSummaries.isPending ? (
-
- ) : (
-
- )}
- {bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
-
+
+
+
+ bulkSummaries.mutate({ roundId: round.id })}
+ disabled={bulkSummaries.isPending}
+ >
+ {bulkSummaries.isPending ? (
+
+ ) : (
+
+ )}
+ {bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
+
+
+
+ Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.
+
+
+
utils.round.get.invalidate({ id: roundId })}
/>
+
+ {/* Override Dialog */}
+ {
+ if (!open) {
+ setOverrideDialog(null)
+ setOverrideReason('')
+ }
+ }}
+ >
+
+
+ Override Filtering Result
+
+ Change the outcome for this project. This will be logged in the
+ audit trail.
+
+
+
+
+ New Outcome
+
+
+
+
+
+ Passed
+ Filtered Out
+ Flagged
+
+
+
+
+ Reason
+ setOverrideReason(e.target.value)}
+ placeholder="Explain why you're overriding..."
+ />
+
+
+
+ setOverrideDialog(null)}
+ >
+ Cancel
+
+
+ {overrideResult.isPending && (
+
+ )}
+ Override
+
+
+
+
+
+ {/* CSV Export Dialog */}
+
)
}
diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx
index 01a9bc7..bb38cac 100644
--- a/src/app/(admin)/admin/settings/page.tsx
+++ b/src/app/(admin)/admin/settings/page.tsx
@@ -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
= {}
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
+ return
}
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 (
{/* Header */}
@@ -69,7 +78,7 @@ export default async function SettingsPage() {
{/* Content */}
}>
-
+
)
diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx
index f247cf2..e3f5b09 100644
--- a/src/components/admin/members-content.tsx
+++ b/src/components/admin/members-content.tsx
@@ -42,11 +42,16 @@ const TAB_ROLES: Record = {
}
const statusColors: Record = {
+ NONE: 'secondary',
ACTIVE: 'success',
INVITED: 'secondary',
SUSPENDED: 'destructive',
}
+const statusLabels: Record = {
+ NONE: 'Not Invited',
+}
+
const roleColors: Record = {
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() {
- {user.status}
+ {statusLabels[user.status] || user.status}
@@ -233,6 +241,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
+ userRole={user.role as RoleValue}
+ currentUserRole={currentUserRole}
/>
@@ -263,7 +273,7 @@ export function MembersContent() {
- {user.status}
+ {statusLabels[user.status] || user.status}
@@ -305,6 +315,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
+ userRole={user.role as RoleValue}
+ currentUserRole={currentUserRole}
/>
diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx
index 658d2cb..75b44af 100644
--- a/src/components/admin/user-actions.tsx
+++ b/src/components/admin/user-actions.tsx
@@ -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 = {
+ 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
+ {canChangeRole && (
+
+
+
+ {updateUser.isPending ? 'Updating...' : 'Change Role'}
+
+
+ {getAvailableRoles().map((role) => (
+ handleRoleChange(role)}
+ disabled={role === userRole}
+ >
+ {role === userRole && }
+
+ {ROLE_LABELS[role]}
+
+
+ ))}
+
+
+ )}
{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,27 +258,46 @@ export function UserMobileActions({
}
return (
-
-
-
-
- Edit
-
-
-
- {isSending ? (
-
- ) : (
-
- )}
- Invite
-
+
+
+
+
+
+ Edit
+
+
+
+ {isSending ? (
+
+ ) : (
+
+ )}
+ Invite
+
+
+ {canChangeRole && (
+
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) => (
+
+ {ROLE_LABELS[role]}
+
+ ))}
+
+ )}
)
}
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
index c8528e2..8ce4fec 100644
--- a/src/components/settings/settings-content.tsx
+++ b/src/components/settings/settings-content.tsx
@@ -61,9 +61,10 @@ function SettingsSkeleton() {
interface SettingsContentProps {
initialSettings: Record
+ 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) {
Locale
-
-
- Email
-
+ {isSuperAdmin && (
+
+
+ Email
+
+ )}
Notif.
@@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
Digest
-
-
- Security
-
+ {isSuperAdmin && (
+
+
+ Security
+
+ )}
Audit
-
-
- AI
-
+ {isSuperAdmin && (
+
+
+ AI
+
+ )}
Tags
@@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
Analytics
-
-
- Storage
-
+ {isSuperAdmin && (
+
+
+ Storage
+
+ )}
@@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
Communication
-
-
- Email
-
+ {isSuperAdmin && (
+
+
+ Email
+
+ )}
Notifications
@@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
Security
-
-
- Security
-
+ {isSuperAdmin && (
+
+
+ Security
+
+ )}
Audit
@@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
Features
-
-
- AI
-
+ {isSuperAdmin && (
+
+
+ AI
+
+ )}
Tags
@@ -274,35 +289,39 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
-
-
Infrastructure
-
-
-
- Storage
-
-
-
+ {isSuperAdmin && (
+
+
Infrastructure
+
+
+
+ Storage
+
+
+
+ )}
{/* Content area */}
-
-
-
- AI Configuration
-
- Configure AI-powered features like smart jury assignment
-
-
-
-
-
-
-
-
+ {isSuperAdmin && (
+
+
+
+ AI Configuration
+
+ Configure AI-powered features like smart jury assignment
+
+
+
+
+
+
+
+
+ )}
@@ -350,19 +369,21 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
-
-
-
- Email Configuration
-
- Configure email settings for notifications and magic links
-
-
-
-
-
-
-
+ {isSuperAdmin && (
+
+
+
+ Email Configuration
+
+ Configure email settings for notifications and magic links
+
+
+
+
+
+
+
+ )}
@@ -378,33 +399,37 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
-
-
-
- File Storage
-
- Configure file upload limits and allowed types
-
-
-
-
-
-
-
+ {isSuperAdmin && (
+
+
+
+ File Storage
+
+ Configure file upload limits and allowed types
+
+
+
+
+
+
+
+ )}
-
-
-
- Security Settings
-
- Configure security and access control settings
-
-
-
-
-
-
-
+ {isSuperAdmin && (
+
+
+
+ Security Settings
+
+ Configure security and access control settings
+
+
+
+
+
+
+
+ )}
@@ -502,26 +527,28 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
-
-
-
-
- Webhooks
-
-
- Configure webhook endpoints for platform events
-
-
-
-
-
-
- Manage Webhooks
-
-
-
-
-
+ {isSuperAdmin && (
+
+
+
+
+ Webhooks
+
+
+ Configure webhook endpoints for platform events
+
+
+
+
+
+
+ Manage Webhooks
+
+
+
+
+
+ )}
>
)
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index df68c44..5b819e8 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -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 = {
@@ -81,17 +85,23 @@ export const projectRouter = router({
// Build where clause
const where: Record = {}
- // 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 = {}
+
+ 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
}),
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index c15435f..923291f 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -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),
},