Fix pipeline config crashes, settings UX, invite roles, seed expertise tags
Build and Push Docker Image / build (push) Successful in 9m51s Details

- Fix critical crash when clicking Edit on INTAKE stage configs: normalize
  DB fileRequirements shape (type/required → acceptedMimeTypes/isRequired),
  add null guard in getActiveCategoriesFromMimeTypes
- Fix config summary display for all stage types to handle seed data key
  mismatches (votingEnabled→juryVotingEnabled, minAssignmentsPerJuror→
  minLoadPerJuror, deterministic.rules→rules, etc.)
- Add AWARD_MASTER role to invite page dropdown and user router validations
- Restructure settings sidebar: Tags and Webhooks as direct links instead
  of nested tabs, remove redundant Quick Links section
- Seed 38 expertise tags across 7 categories (Marine Science, Technology,
  Policy, Conservation, Business, Education, Engineering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-14 11:40:44 +01:00
parent ae0ac58547
commit c88f540633
7 changed files with 187 additions and 118 deletions

View File

@ -136,6 +136,80 @@ async function main() {
} }
console.log(` Created ${settings.length} settings`) console.log(` Created ${settings.length} settings`)
// ==========================================================================
// 1b. Expertise Tags
// ==========================================================================
console.log('\n🏷 Creating expertise tags...')
const expertiseTags = [
// Marine Science
{ name: 'Marine Biology', description: 'Study of marine organisms and ecosystems', category: 'Marine Science', color: '#0284c7', sortOrder: 0 },
{ name: 'Oceanography', description: 'Physical, chemical, and biological ocean science', category: 'Marine Science', color: '#0284c7', sortOrder: 1 },
{ name: 'Coral Reef Ecology', description: 'Coral reef ecosystems, health, and restoration', category: 'Marine Science', color: '#0284c7', sortOrder: 2 },
{ name: 'Marine Biodiversity', description: 'Species diversity and conservation in marine environments', category: 'Marine Science', color: '#0284c7', sortOrder: 3 },
{ name: 'Ocean Acidification', description: 'Chemical changes in ocean pH and their impacts', category: 'Marine Science', color: '#0284c7', sortOrder: 4 },
{ name: 'Deep Sea Research', description: 'Exploration and study of deep ocean environments', category: 'Marine Science', color: '#0284c7', sortOrder: 5 },
// Technology
{ name: 'Ocean Sensors & IoT', description: 'Sensor networks and IoT for ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 10 },
{ name: 'AI & Machine Learning', description: 'AI applications for ocean data analysis and prediction', category: 'Technology', color: '#7c3aed', sortOrder: 11 },
{ name: 'Robotics & AUVs', description: 'Autonomous underwater vehicles and marine robotics', category: 'Technology', color: '#7c3aed', sortOrder: 12 },
{ name: 'Satellite Remote Sensing', description: 'Earth observation and satellite-based ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 13 },
{ name: 'Marine Biotechnology', description: 'Biotechnological solutions from marine organisms', category: 'Technology', color: '#7c3aed', sortOrder: 14 },
{ name: 'Desalination', description: 'Water desalination and purification technologies', category: 'Technology', color: '#7c3aed', sortOrder: 15 },
// Policy
{ name: 'Maritime Law', description: 'International maritime regulations and legal frameworks', category: 'Policy', color: '#053d57', sortOrder: 20 },
{ name: 'Ocean Governance', description: 'International ocean policy and governance frameworks', category: 'Policy', color: '#053d57', sortOrder: 21 },
{ name: 'Marine Protected Areas', description: 'MPA design, management, and policy', category: 'Policy', color: '#053d57', sortOrder: 22 },
{ name: 'Climate Policy', description: 'Climate change mitigation and adaptation policy', category: 'Policy', color: '#053d57', sortOrder: 23 },
{ name: 'Sustainable Development Goals', description: 'SDG 14 (Life Below Water) and related goals', category: 'Policy', color: '#053d57', sortOrder: 24 },
// Conservation
{ name: 'Habitat Restoration', description: 'Restoration of mangroves, seagrass, and coastal habitats', category: 'Conservation', color: '#059669', sortOrder: 30 },
{ name: 'Species Protection', description: 'Endangered marine species conservation programs', category: 'Conservation', color: '#059669', sortOrder: 31 },
{ name: 'Pollution Reduction', description: 'Marine pollution prevention and cleanup', category: 'Conservation', color: '#059669', sortOrder: 32 },
{ name: 'Plastic Waste', description: 'Plastic pollution reduction and circular solutions', category: 'Conservation', color: '#059669', sortOrder: 33 },
{ name: 'Blue Carbon', description: 'Carbon sequestration in coastal and marine ecosystems', category: 'Conservation', color: '#059669', sortOrder: 34 },
{ name: 'Coastal Resilience', description: 'Building resilience in coastal communities and ecosystems', category: 'Conservation', color: '#059669', sortOrder: 35 },
// Business
{ name: 'Blue Economy', description: 'Sustainable economic use of ocean resources', category: 'Business', color: '#557f8c', sortOrder: 40 },
{ name: 'Impact Investing', description: 'Investment strategies for ocean-positive outcomes', category: 'Business', color: '#557f8c', sortOrder: 41 },
{ name: 'Startup Scaling', description: 'Scaling ocean-focused startups and ventures', category: 'Business', color: '#557f8c', sortOrder: 42 },
{ name: 'Sustainable Aquaculture', description: 'Sustainable fish farming and aquaculture practices', category: 'Business', color: '#557f8c', sortOrder: 43 },
{ name: 'Sustainable Shipping', description: 'Green shipping, fuel alternatives, and port operations', category: 'Business', color: '#557f8c', sortOrder: 44 },
{ name: 'Circular Economy', description: 'Circular models for marine industries and products', category: 'Business', color: '#557f8c', sortOrder: 45 },
// Education
{ name: 'Ocean Literacy', description: 'Public education and awareness about ocean health', category: 'Education', color: '#ea580c', sortOrder: 50 },
{ name: 'Community Engagement', description: 'Engaging coastal communities in conservation', category: 'Education', color: '#ea580c', sortOrder: 51 },
{ name: 'Citizen Science', description: 'Public participation in ocean data collection', category: 'Education', color: '#ea580c', sortOrder: 52 },
{ name: 'Capacity Building', description: 'Training and skill development for ocean stewardship', category: 'Education', color: '#ea580c', sortOrder: 53 },
// Engineering
{ name: 'Renewable Ocean Energy', description: 'Wave, tidal, and offshore wind energy systems', category: 'Engineering', color: '#be185d', sortOrder: 60 },
{ name: 'Coastal Engineering', description: 'Infrastructure design for coastal protection', category: 'Engineering', color: '#be185d', sortOrder: 61 },
{ name: 'Water Treatment', description: 'Wastewater treatment and water quality engineering', category: 'Engineering', color: '#be185d', sortOrder: 62 },
{ name: 'Marine Materials', description: 'Biodegradable and sustainable materials for marine use', category: 'Engineering', color: '#be185d', sortOrder: 63 },
]
for (const tag of expertiseTags) {
await prisma.expertiseTag.upsert({
where: { name: tag.name },
update: {},
create: {
name: tag.name,
description: tag.description,
category: tag.category,
color: tag.color,
sortOrder: tag.sortOrder,
isActive: true,
},
})
}
console.log(` Created ${expertiseTags.length} expertise tags across ${new Set(expertiseTags.map(t => t.category)).size} categories`)
// ========================================================================== // ==========================================================================
// 2. Admin/Staff Users // 2. Admin/Staff Users
// ========================================================================== // ==========================================================================

View File

@ -72,7 +72,7 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete' type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment { interface Assignment {
projectId: string projectId: string
@ -104,6 +104,7 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin', SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin', PROGRAM_ADMIN: 'Program Admin',
AWARD_MASTER: 'Award Master',
JURY_MEMBER: 'Jury Member', JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor', MENTOR: 'Mentor',
OBSERVER: 'Observer', OBSERVER: 'Observer',
@ -276,6 +277,7 @@ export default function MemberInvitePage() {
// Fetch current user to check role // Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery() const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const isAdmin = isSuperAdmin || currentUser?.role === 'PROGRAM_ADMIN'
const bulkCreate = trpc.user.bulkCreate.useMutation({ const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => { onSuccess: () => {
@ -406,6 +408,8 @@ export default function MemberInvitePage() {
? 'SUPER_ADMIN' ? 'SUPER_ADMIN'
: rawRole === 'PROGRAM_ADMIN' : rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN' ? 'PROGRAM_ADMIN'
: rawRole === 'AWARD_MASTER'
? 'AWARD_MASTER'
: rawRole === 'MENTOR' : rawRole === 'MENTOR'
? 'MENTOR' ? 'MENTOR'
: rawRole === 'OBSERVER' : rawRole === 'OBSERVER'
@ -413,7 +417,7 @@ export default function MemberInvitePage() {
: 'JURY_MEMBER' : 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email) const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = (role === 'PROGRAM_ADMIN' || role === 'SUPER_ADMIN') && !isSuperAdmin const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate && email) seenEmails.add(email) if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
return { return {
email, email,
@ -428,7 +432,7 @@ export default function MemberInvitePage() {
: isDuplicate : isDuplicate
? 'Duplicate email' ? 'Duplicate email'
: isUnauthorizedAdmin : isUnauthorizedAdmin
? 'Only super admins can invite program admins' ? 'Only super admins can invite super admins'
: undefined, : undefined,
} }
}) })
@ -449,7 +453,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase() const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(email) const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email) const isDuplicate = seenEmails.has(email)
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin const isUnauthorizedAdmin = r.role === 'SUPER_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate) seenEmails.add(email) if (isValidFormat && !isDuplicate) seenEmails.add(email)
return { return {
email, email,
@ -464,7 +468,7 @@ export default function MemberInvitePage() {
: isDuplicate : isDuplicate
? 'Duplicate email' ? 'Duplicate email'
: isUnauthorizedAdmin : isUnauthorizedAdmin
? 'Only super admins can invite program admins' ? 'Only super admins can invite super admins'
: undefined, : undefined,
} }
}) })
@ -547,7 +551,7 @@ export default function MemberInvitePage() {
Add members individually or upload a CSV file Add members individually or upload a CSV file
{isSuperAdmin && ( {isSuperAdmin && (
<span className="block mt-1 text-primary font-medium"> <span className="block mt-1 text-primary font-medium">
As a super admin, you can also invite program admins As a super admin, you can also invite super admins
</span> </span>
)} )}
</CardDescription> </CardDescription>
@ -658,11 +662,16 @@ export default function MemberInvitePage() {
Super Admin Super Admin
</SelectItem> </SelectItem>
)} )}
{isSuperAdmin && ( {isAdmin && (
<SelectItem value="PROGRAM_ADMIN"> <SelectItem value="PROGRAM_ADMIN">
Program Admin Program Admin
</SelectItem> </SelectItem>
)} )}
{isAdmin && (
<SelectItem value="AWARD_MASTER">
Award Master
</SelectItem>
)}
<SelectItem value="JURY_MEMBER"> <SelectItem value="JURY_MEMBER">
Jury Member Jury Member
</SelectItem> </SelectItem>

View File

@ -262,7 +262,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<FileTypePicker <FileTypePicker
value={req.acceptedMimeTypes} value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) => onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes }) updateFileReq(index, { acceptedMimeTypes: mimeTypes })
} }

View File

@ -3,7 +3,7 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { EditableCard } from '@/components/ui/editable-card' import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { import {
Inbox, Inbox,
Filter, Filter,
@ -78,8 +78,8 @@ function ConfigSummary({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span> <span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy}</span> <span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
{config.lateGraceHours > 0 && ( {(config.lateGraceHours ?? 0) > 0 && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({config.lateGraceHours}h grace) ({config.lateGraceHours}h grace)
</span> </span>
@ -94,32 +94,26 @@ function ConfigSummary({
} }
case 'FILTER': { case 'FILTER': {
const config = configJson as unknown as FilterConfig const raw = configJson as Record<string, unknown>
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as unknown[] | undefined
const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0
const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai)
return ( return (
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span> <span className="text-muted-foreground">Rules:</span>
<span>{config.rules?.length ?? 0} eligibility rules</span> <span>{ruleCount} eligibility rules</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span> <span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'} {aiEnabled ? 'Enabled' : 'Disabled'}
</Badge> </Badge>
</div> </div>
{config.aiRubricEnabled && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Confidence:</span>
<span>
High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '}
{config.aiConfidenceThresholds?.medium ?? 0.6}
</span>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Manual Queue:</span> <span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'} {(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -127,23 +121,27 @@ function ConfigSummary({
} }
case 'EVALUATION': { case 'EVALUATION': {
const config = configJson as unknown as EvaluationConfig const raw = configJson as Record<string, unknown>
const reviews = (raw.requiredReviews as number) ?? 3
const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5
const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20
const overflow = (raw.overflowPolicy as string) ?? 'queue'
return ( return (
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span> <span className="text-muted-foreground">Required Reviews:</span>
<span>{config.requiredReviews ?? 3}</span> <span>{reviews}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span> <span className="text-muted-foreground">Load per Juror:</span>
<span> <span>
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20} {minLoad} - {maxLoad}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span> <span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize"> <span className="capitalize">
{(config.overflowPolicy ?? 'queue').replace('_', ' ')} {overflow.replace('_', ' ')}
</span> </span>
</div> </div>
</div> </div>
@ -182,29 +180,32 @@ function ConfigSummary({
} }
case 'LIVE_FINAL': { case 'LIVE_FINAL': {
const config = configJson as unknown as LiveFinalConfig const raw = configJson as Record<string, unknown>
const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false
const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false
const audienceWeight = (raw.audienceVoteWeight as number) ?? 0
return ( return (
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span> <span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'} {juryEnabled ? 'Enabled' : 'Disabled'}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span> <span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'} {audienceEnabled ? 'Enabled' : 'Disabled'}
</Badge> </Badge>
{config.audienceVotingEnabled && ( {audienceEnabled && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({config.audienceVoteWeight}% weight) ({Math.round(audienceWeight * 100)}% weight)
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Reveal:</span> <span className="text-muted-foreground">Reveal:</span>
<span className="capitalize">{config.revealPolicy ?? 'ceremony'}</span> <span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
</div> </div>
</div> </div>
) )
@ -260,10 +261,21 @@ export function StageConfigEditor({
const renderEditor = () => { const renderEditor = () => {
switch (stageType) { switch (stageType) {
case 'INTAKE': { case 'INTAKE': {
const config = { const rawConfig = {
...defaultIntakeConfig(), ...defaultIntakeConfig(),
...(localConfig as object), ...(localConfig as object),
} as IntakeConfig } as IntakeConfig
// Deep-normalize fileRequirements to handle DB shape mismatches
const config: IntakeConfig = {
...rawConfig,
fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({
name: req.name ?? '',
description: req.description ?? '',
acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'],
maxSizeMB: req.maxSizeMB ?? 50,
isRequired: req.isRequired ?? (req as Record<string, unknown>).required === true,
})),
}
return ( return (
<IntakeSection <IntakeSection
config={config} config={config}
@ -272,10 +284,21 @@ export function StageConfigEditor({
) )
} }
case 'FILTER': { case 'FILTER': {
const config = { const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as FilterConfig['rules'] | undefined
const seedBands = raw.confidenceBands as Record<string, Record<string, number>> | undefined
const config: FilterConfig = {
...defaultFilterConfig(), ...defaultFilterConfig(),
...(localConfig as object), ...raw,
} as FilterConfig rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules,
aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai,
aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? {
high: seedBands.high?.threshold ?? 0.85,
medium: seedBands.medium?.threshold ?? 0.6,
low: seedBands.low?.threshold ?? 0.4,
} : defaultFilterConfig().aiConfidenceThresholds),
}
return ( return (
<FilteringSection <FilteringSection
config={config} config={config}
@ -284,10 +307,15 @@ export function StageConfigEditor({
) )
} }
case 'EVALUATION': { case 'EVALUATION': {
const config = { const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
const config: EvaluationConfig = {
...defaultEvaluationConfig(), ...defaultEvaluationConfig(),
...(localConfig as object), ...raw,
} as EvaluationConfig requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews,
minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror,
maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror,
}
return ( return (
<AssignmentSection <AssignmentSection
config={config} config={config}
@ -296,10 +324,15 @@ export function StageConfigEditor({
) )
} }
case 'LIVE_FINAL': { case 'LIVE_FINAL': {
const config = { const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
const config: LiveFinalConfig = {
...defaultLiveConfig(), ...defaultLiveConfig(),
...(localConfig as object), ...raw,
} as LiveFinalConfig juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true,
audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false,
audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0,
}
return ( return (
<LiveFinalsSection <LiveFinalsSection
config={config} config={config}

View File

@ -27,7 +27,6 @@ import {
Webhook, Webhook,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form' import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card' import { AIUsageCard } from './ai-usage-card'
@ -199,10 +198,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
AI AI
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger value="tags" className="gap-2 shrink-0"> <Link href="/admin/settings/tags" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Tags className="h-4 w-4" /> <Tags className="h-4 w-4" />
Tags Tags
</TabsTrigger> </Link>
<TabsTrigger value="analytics" className="gap-2 shrink-0"> <TabsTrigger value="analytics" className="gap-2 shrink-0">
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
Analytics Analytics
@ -213,6 +212,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Storage Storage
</TabsTrigger> </TabsTrigger>
)} )}
{isSuperAdmin && (
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-center gap-2 shrink-0 rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Webhook className="h-4 w-4" />
Webhooks
</Link>
)}
</TabsList> </TabsList>
<div className="lg:flex lg:gap-8"> <div className="lg:flex lg:gap-8">
@ -279,10 +284,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
AI AI
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted"> <Link href="/admin/settings/tags" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Tags className="h-4 w-4" /> <Tags className="h-4 w-4" />
Tags Tags
</TabsTrigger> <ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
<TabsTrigger value="analytics" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted"> <TabsTrigger value="analytics" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
Analytics Analytics
@ -298,6 +304,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Storage Storage
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<Link href="/admin/settings/webhooks" className="inline-flex items-center justify-start gap-2 w-full px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<Webhook className="h-4 w-4" />
Webhooks
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
</div> </div>
)} )}
</nav> </nav>
@ -325,40 +336,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsContent> </TabsContent>
)} )}
<TabsContent value="tags">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tags className="h-5 w-5" />
Expertise Tags
</CardTitle>
<CardDescription>
Manage tags used for jury expertise, project categorization, and AI-powered matching
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Expertise tags are used across the platform to:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Categorize jury members by their areas of expertise</li>
<li>Tag projects for better organization and filtering</li>
<li>Power AI-based project tagging</li>
<li>Enable smart jury-project matching</li>
</ul>
<Button asChild>
<Link href="/admin/settings/tags">
<Tags className="mr-2 h-4 w-4" />
Manage Expertise Tags
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="branding"> <TabsContent value="branding">
<AnimatedCard> <AnimatedCard>
<Card> <Card>
@ -528,31 +505,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</div>{/* end lg:flex */} </div>{/* end lg:flex */}
</Tabs> </Tabs>
{/* Quick Links to sub-pages */}
<div className="grid gap-4 sm:grid-cols-2">
{isSuperAdmin && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />
Webhooks
</CardTitle>
<CardDescription>
Configure webhook endpoints for platform events
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/admin/settings/webhooks">
<Webhook className="mr-2 h-4 w-4" />
Manage Webhooks
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</> </>
) )
} }

View File

@ -16,6 +16,7 @@ export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
/** Get active category IDs from a list of mime types */ /** Get active category IDs from a list of mime types */
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] { export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
if (!mimeTypes || !Array.isArray(mimeTypes)) return []
return FILE_TYPE_CATEGORIES.filter((cat) => return FILE_TYPE_CATEGORIES.filter((cat) =>
cat.mimeTypes.some((mime) => mimeTypes.includes(mime)) cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
).map((cat) => cat.id) ).map((cat) => cat.id)

View File

@ -183,8 +183,8 @@ export const userRouter = router({
list: adminProcedure list: adminProcedure
.input( .input(
z.object({ z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(), search: z.string().optional(),
page: z.number().int().min(1).default(1), page: z.number().int().min(1).default(1),
@ -274,7 +274,7 @@ export const userRouter = router({
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(), name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional(), maxAssignments: z.number().int().min(1).max(100).optional(),
}) })
@ -339,7 +339,7 @@ export const userRouter = router({
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
@ -472,7 +472,7 @@ export const userRouter = router({
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(), name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members // Optional pre-assignments for jury members
assignments: z assignments: z