Fix pipeline config crashes, settings UX, invite roles, seed expertise tags

- 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:
2026-02-14 11:40:44 +01:00
parent ae0ac58547
commit c88f540633
7 changed files with 187 additions and 118 deletions

View File

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

View File

@@ -3,7 +3,7 @@
import { useState, useCallback } from 'react'
import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import {
Inbox,
Filter,
@@ -78,8 +78,8 @@ function ConfigSummary({
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy}</span>
{config.lateGraceHours > 0 && (
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
{(config.lateGraceHours ?? 0) > 0 && (
<span className="text-muted-foreground">
({config.lateGraceHours}h grace)
</span>
@@ -94,32 +94,26 @@ function ConfigSummary({
}
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 (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span>
<span>{config.rules?.length ?? 0} eligibility rules</span>
<span>{ruleCount} eligibility rules</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]">
{config.aiRubricEnabled ? 'Enabled' : 'Disabled'}
{aiEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</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">
<span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]">
{config.manualQueueEnabled ? 'Enabled' : 'Disabled'}
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
@@ -127,23 +121,27 @@ function ConfigSummary({
}
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 (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span>
<span>{config.requiredReviews ?? 3}</span>
<span>{reviews}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span>
<span>
{config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20}
{minLoad} - {maxLoad}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize">
{(config.overflowPolicy ?? 'queue').replace('_', ' ')}
{overflow.replace('_', ' ')}
</span>
</div>
</div>
@@ -182,29 +180,32 @@ function ConfigSummary({
}
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 (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]">
{config.juryVotingEnabled ? 'Enabled' : 'Disabled'}
{juryEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]">
{config.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
{audienceEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{config.audienceVotingEnabled && (
{audienceEnabled && (
<span className="text-muted-foreground">
({config.audienceVoteWeight}% weight)
({Math.round(audienceWeight * 100)}% weight)
</span>
)}
</div>
<div className="flex items-center gap-2">
<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>
)
@@ -260,10 +261,21 @@ export function StageConfigEditor({
const renderEditor = () => {
switch (stageType) {
case 'INTAKE': {
const config = {
const rawConfig = {
...defaultIntakeConfig(),
...(localConfig as object),
} 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 (
<IntakeSection
config={config}
@@ -272,10 +284,21 @@ export function StageConfigEditor({
)
}
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(),
...(localConfig as object),
} as FilterConfig
...raw,
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 (
<FilteringSection
config={config}
@@ -284,10 +307,15 @@ export function StageConfigEditor({
)
}
case 'EVALUATION': {
const config = {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
const config: EvaluationConfig = {
...defaultEvaluationConfig(),
...(localConfig as object),
} as EvaluationConfig
...raw,
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 (
<AssignmentSection
config={config}
@@ -296,10 +324,15 @@ export function StageConfigEditor({
)
}
case 'LIVE_FINAL': {
const config = {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
const config: LiveFinalConfig = {
...defaultLiveConfig(),
...(localConfig as object),
} as LiveFinalConfig
...raw,
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 (
<LiveFinalsSection
config={config}