Simplify routing to award assignment, seed all CSV entries, fix category mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s

- Remove RoutingRule model and routing engine (replaced by direct award assignment)
- Simplify RoutingMode enum: PARALLEL/POST_MAIN → SHARED, keep EXCLUSIVE
- Remove routing router, routing-rules-editor, and related tests
- Update pipeline, award, and notification code to remove routing references
- Seed: include all CSV entries (no filtering/dedup), AI screening handles duplicates
- Seed: fix non-breaking space (U+00A0) bug in category/issue mapping
- Stage filtering: add duplicate detection that flags projects for admin review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:25:05 +01:00
parent 382570cebd
commit 9ab4717f96
23 changed files with 249 additions and 2449 deletions

View File

@@ -18,8 +18,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Plus, X, Loader2, Sparkles, AlertCircle } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
// ─── Field & Operator Definitions ────────────────────────────────────────────
@@ -289,22 +288,8 @@ function AIMode({
explanation: string
} | null>(null)
const parseRule = trpc.routing.parseNaturalLanguageRule.useMutation({
onSuccess: (data) => {
setResult(data)
},
onError: (error) => {
toast.error(error.message)
},
})
const handleGenerate = () => {
if (!text.trim()) return
if (!pipelineId) {
toast.error('Pipeline ID is required for AI parsing')
return
}
parseRule.mutate({ text: text.trim(), pipelineId })
toast.error('AI rule parsing is not currently available')
}
const handleApply = () => {
@@ -322,19 +307,14 @@ function AIMode({
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
value={text}
onChange={(e) => setText(e.target.value)}
disabled={parseRule.isPending}
/>
<Button
type="button"
size="sm"
onClick={handleGenerate}
disabled={!text.trim() || parseRule.isPending || !pipelineId}
disabled={!text.trim() || !pipelineId}
>
{parseRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Generate Rule
</Button>
</div>

View File

@@ -1,633 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PredicateBuilder } from '@/components/admin/pipeline/predicate-builder'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Plus,
Save,
Trash2,
Loader2,
Power,
PowerOff,
ChevronDown,
ArrowRight,
HelpCircle,
Settings2,
Route,
} from 'lucide-react'
// ─── Types ───────────────────────────────────────────────────────────────────
type StageLite = {
id: string
name: string
sortOrder: number
}
type TrackLite = {
id: string
name: string
stages: StageLite[]
}
type RoutingRulesEditorProps = {
pipelineId: string
tracks: TrackLite[]
}
type RuleDraft = {
id: string
name: string
scope: 'global' | 'track' | 'stage'
sourceTrackId: string | null
destinationTrackId: string
destinationStageId: string | null
priority: number
isActive: boolean
predicateJson: Record<string, unknown>
}
const DEFAULT_PREDICATE = {
field: 'competitionCategory',
operator: 'eq',
value: 'STARTUP',
}
// ─── Predicate Summarizer ────────────────────────────────────────────────────
const FIELD_LABELS: Record<string, string> = {
competitionCategory: 'Competition Category',
oceanIssue: 'Ocean Issue',
country: 'Country',
geographicZone: 'Geographic Zone',
wantsMentorship: 'Wants Mentorship',
tags: 'Tags',
}
const OPERATOR_LABELS: Record<string, string> = {
eq: 'is',
neq: 'is not',
in: 'is one of',
contains: 'contains',
gt: '>',
lt: '<',
// Legacy operators
equals: 'is',
not_equals: 'is not',
}
function summarizeValue(val: unknown): string {
if (Array.isArray(val)) return val.join(', ')
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
return String(val ?? '')
}
function summarizePredicate(predicate: Record<string, unknown>): string {
// Simple condition
if (typeof predicate.field === 'string' && typeof predicate.operator === 'string') {
const field = FIELD_LABELS[predicate.field] || predicate.field
const op = OPERATOR_LABELS[predicate.operator as string] || predicate.operator
const val = summarizeValue(predicate.value)
return `${field} ${op} ${val}`
}
// Compound condition
if (predicate.logic && Array.isArray(predicate.conditions)) {
const conditions = predicate.conditions as Array<Record<string, unknown>>
if (conditions.length === 0) return 'No conditions'
const parts = conditions.map((c) => {
if (typeof c.field === 'string' && typeof c.operator === 'string') {
const field = FIELD_LABELS[c.field] || c.field
const op = OPERATOR_LABELS[c.operator as string] || c.operator
const val = summarizeValue(c.value)
return `${field} ${op} ${val}`
}
return 'Custom condition'
})
const joiner = predicate.logic === 'or' ? ' or ' : ' and '
return parts.join(joiner)
}
return 'Custom condition'
}
// ─── Rule Card ───────────────────────────────────────────────────────────────
function RuleCard({
draft,
index,
tracks,
pipelineId,
expandedId,
onToggleExpand,
onUpdateDraft,
onSave,
onDelete,
onToggleActive,
isSaving,
isDeleting,
isToggling,
}: {
draft: RuleDraft
index: number
tracks: TrackLite[]
pipelineId: string
expandedId: string | null
onToggleExpand: (id: string) => void
onUpdateDraft: (id: string, updates: Partial<RuleDraft>) => void
onSave: (id: string) => void
onDelete: (id: string) => void
onToggleActive: (id: string, isActive: boolean) => void
isSaving: boolean
isDeleting: boolean
isToggling: boolean
}) {
const [showAdvanced, setShowAdvanced] = useState(false)
const isExpanded = expandedId === draft.id
const destinationTrack = tracks.find((t) => t.id === draft.destinationTrackId)
const destinationTrackName = destinationTrack?.name || 'Unknown Track'
const conditionSummary = summarizePredicate(draft.predicateJson)
return (
<Collapsible open={isExpanded} onOpenChange={() => onToggleExpand(draft.id)}>
{/* Collapsed header */}
<CollapsibleTrigger asChild>
<button
type="button"
className="w-full flex items-center gap-3 rounded-md border px-3 py-2.5 text-left hover:bg-muted/50 transition-colors"
>
{/* Priority badge */}
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold">
#{index + 1}
</span>
{/* Active dot */}
<span
className={`h-2 w-2 shrink-0 rounded-full ${
draft.isActive ? 'bg-green-500' : 'bg-gray-300'
}`}
/>
{/* Summary */}
<span className="flex-1 text-xs truncate">
<span className="text-muted-foreground">Route projects where </span>
<span className="font-medium">{conditionSummary}</span>
<span className="text-muted-foreground"> &rarr; </span>
<span className="font-medium">{destinationTrackName}</span>
</span>
{/* Chevron */}
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
</CollapsibleTrigger>
{/* Expanded content */}
<CollapsibleContent>
<div className="border border-t-0 rounded-b-md px-4 py-4 space-y-4 -mt-px">
{/* Rule name */}
<div className="space-y-1">
<Label className="text-xs">Rule Name</Label>
<Input
className="h-8 text-sm"
value={draft.name}
onChange={(e) => onUpdateDraft(draft.id, { name: e.target.value })}
/>
</div>
{/* Track routing flow: Source → Destination Track → Destination Stage */}
<div className="space-y-1.5">
<Label className="text-xs">Route To</Label>
<div className="flex items-center gap-2 flex-wrap">
<div className="w-[180px]">
<Select
value={draft.sourceTrackId ?? '__none__'}
onValueChange={(value) =>
onUpdateDraft(draft.id, {
sourceTrackId: value === '__none__' ? null : value,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Any Track</SelectItem>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="w-[180px]">
<Select
value={draft.destinationTrackId}
onValueChange={(value) => {
const track = tracks.find((t) => t.id === value)
onUpdateDraft(draft.id, {
destinationTrackId: value,
destinationStageId: track?.stages[0]?.id ?? null,
})
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Destination Track" />
</SelectTrigger>
<SelectContent>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="w-[180px]">
<Select
value={draft.destinationStageId ?? '__none__'}
onValueChange={(value) =>
onUpdateDraft(draft.id, {
destinationStageId: value === '__none__' ? null : value,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Track Start</SelectItem>
{(destinationTrack?.stages ?? [])
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Predicate builder */}
<div className="space-y-1.5">
<Label className="text-xs">Conditions</Label>
<PredicateBuilder
value={draft.predicateJson}
onChange={(predicate) =>
onUpdateDraft(draft.id, { predicateJson: predicate })
}
pipelineId={pipelineId}
/>
</div>
{/* Advanced settings (collapsible) */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5 text-muted-foreground"
>
<Settings2 className="h-3 w-3" />
Advanced Settings
<ChevronDown
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 grid gap-3 sm:grid-cols-2 rounded-md border p-3 bg-muted/30">
<TooltipProvider delayDuration={300}>
<div className="space-y-1">
<div className="flex items-center gap-1">
<Label className="text-xs">Scope</Label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
Global: applies to all projects. Track/Stage: only applies to projects in a specific track or stage.
</TooltipContent>
</Tooltip>
</div>
<Select
value={draft.scope}
onValueChange={(value) =>
onUpdateDraft(draft.id, { scope: value as RuleDraft['scope'] })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="track">Track</SelectItem>
<SelectItem value="stage">Stage</SelectItem>
</SelectContent>
</Select>
</div>
</TooltipProvider>
<div className="space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
className="h-8 text-xs"
value={draft.priority}
onChange={(e) =>
onUpdateDraft(draft.id, {
priority: parseInt(e.target.value, 10) || 0,
})
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Action bar */}
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => onToggleActive(draft.id, !draft.isActive)}
disabled={isToggling}
>
{draft.isActive ? (
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
) : (
<Power className="mr-1.5 h-3.5 w-3.5" />
)}
{draft.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 text-xs text-destructive hover:text-destructive"
onClick={() => onDelete(draft.id)}
disabled={isDeleting}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
<Button
type="button"
size="sm"
className="h-8 text-xs"
onClick={() => onSave(draft.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function RoutingRulesEditor({
pipelineId,
tracks,
}: RoutingRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
pipelineId,
})
const upsertRule = trpc.routing.upsertRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule saved')
},
onError: (error) => toast.error(error.message),
})
const toggleRule = trpc.routing.toggleRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.routing.deleteRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule deleted')
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => b.priority - a.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
scope: rule.scope as RuleDraft['scope'],
sourceTrackId: rule.sourceTrackId ?? null,
destinationTrackId: rule.destinationTrackId,
destinationStageId: rule.destinationStageId ?? null,
priority: rule.priority,
isActive: rule.isActive,
predicateJson: (rule.predicateJson as Record<string, unknown>) ?? {},
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const defaultTrack = tracks[0]
if (!defaultTrack) {
toast.error('Create a track before adding routing rules')
return
}
const result = await upsertRule.mutateAsync({
pipelineId,
name: `Routing Rule ${orderedRules.length + 1}`,
scope: 'global',
sourceTrackId: null,
destinationTrackId: defaultTrack.id,
destinationStageId: defaultTrack.stages[0]?.id ?? null,
priority: orderedRules.length + 1,
isActive: true,
predicateJson: DEFAULT_PREDICATE,
})
// Auto-expand the new rule
if (result?.id) {
setExpandedId(result.id)
}
}
const handleSaveRule = async (id: string) => {
const draft = drafts[id]
if (!draft) return
await upsertRule.mutateAsync({
id: draft.id,
pipelineId,
name: draft.name.trim(),
scope: draft.scope,
sourceTrackId: draft.sourceTrackId,
destinationTrackId: draft.destinationTrackId,
destinationStageId: draft.destinationStageId,
priority: draft.priority,
isActive: draft.isActive,
predicateJson: draft.predicateJson,
})
}
const handleUpdateDraft = (id: string, updates: Partial<RuleDraft>) => {
setDrafts((prev) => ({
...prev,
[id]: { ...prev[id], ...updates },
}))
}
const handleToggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}
if (isLoading) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Route className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Routing Rules</h3>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading routing rules...
</div>
</div>
)
}
return (
<div className="space-y-3">
{/* Section header */}
<TooltipProvider delayDuration={300}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Route className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Routing Rules</h3>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[280px] text-xs">
Routing rules determine which track a project enters based on its attributes. Rules are evaluated in priority order -- the first matching rule wins.
</TooltipContent>
</Tooltip>
</div>
<Button
type="button"
size="sm"
className="h-8"
onClick={handleCreateRule}
disabled={upsertRule.isPending}
>
{upsertRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" />
)}
Add Rule
</Button>
</div>
</TooltipProvider>
{/* Rule list */}
{orderedRules.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center">
<Route className="mx-auto h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm font-medium text-muted-foreground">No routing rules yet</p>
<p className="text-xs text-muted-foreground mt-1">
Add a rule to automatically route projects into tracks based on their attributes.
</p>
</div>
) : (
<div className="space-y-2">
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
return (
<RuleCard
key={rule.id}
draft={draft}
index={index}
tracks={tracks}
pipelineId={pipelineId}
expandedId={expandedId}
onToggleExpand={handleToggleExpand}
onUpdateDraft={handleUpdateDraft}
onSave={handleSaveRule}
onDelete={(id) => deleteRule.mutate({ id })}
onToggleActive={(id, isActive) => toggleRule.mutate({ id, isActive })}
isSaving={upsertRule.isPending}
isDeleting={deleteRule.isPending}
isToggling={toggleRule.isPending}
/>
)
})}
</div>
)}
</div>
)
}

View File

@@ -149,10 +149,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
<InfoTooltip content="Shared: projects compete in the main track and this award simultaneously. Exclusive: projects are routed exclusively to this track." />
</div>
<Select
value={track.routingModeDefault ?? 'PARALLEL'}
value={track.routingModeDefault ?? 'SHARED'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
@@ -164,15 +164,12 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PARALLEL">
Parallel Runs alongside main track
<SelectItem value="SHARED">
Shared Projects compete in main + this award
</SelectItem>
<SelectItem value="EXCLUSIVE">
Exclusive Projects enter only this track
</SelectItem>
<SelectItem value="POST_MAIN">
Post-Main After main track completes
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -30,11 +30,6 @@ const NOTIFICATION_EVENTS = [
label: 'Assignments Generated',
description: 'When jury assignments are created or updated',
},
{
key: 'routing.executed',
label: 'Routing Executed',
description: 'When projects are routed into tracks/stages',
},
{
key: 'live.cursor.updated',
label: 'Live Cursor Updated',