Simplify routing to award assignment, seed all CSV entries, fix category mapping

- 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:
Matt 2026-02-15 14:25:05 +01:00
parent 382570cebd
commit 9ab4717f96
23 changed files with 249 additions and 2449 deletions

View File

@ -124,26 +124,7 @@ async function runChecks(): Promise<CheckResult[]> {
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
})
// 8. RoutingRule destinations reference valid tracks in same pipeline
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count FROM "RoutingRule" rr
WHERE rr."destinationTrackId" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "Track" t
WHERE t.id = rr."destinationTrackId"
AND t."pipelineId" = rr."pipelineId"
)
`
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
results.push({
name: 'RoutingRule destinations reference valid tracks in same pipeline',
passed: badRouteCount === 0,
details: badRouteCount === 0
? 'All routing rules reference valid destination tracks'
: `Found ${badRouteCount} routing rules with invalid destinations`,
})
// 9. LiveProgressCursor references valid stage
// 8. LiveProgressCursor references valid stage
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")

View File

@ -0,0 +1,28 @@
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL → SHARED
-- Drop RoutingRule table (routing is now handled via award assignment)
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
-- 2. Rename PARALLEL → SHARED in the enum
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
-- 3. Remove POST_MAIN from the enum
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
-- Create new enum without POST_MAIN
-- Actually, since we already renamed PARALLEL to SHARED and converted POST_MAIN rows,
-- we just need to remove the POST_MAIN value. PostgreSQL 13+ doesn't support dropping
-- enum values natively, but since all rows are already migrated, we can:
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
ALTER TABLE "Track"
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
USING ("routingMode"::text::"RoutingMode_new");
DROP TYPE "RoutingMode";
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
-- 4. Drop the RoutingRule table (no longer needed)
DROP TABLE IF EXISTS "RoutingRule";

View File

@ -163,9 +163,8 @@ enum TrackKind {
}
enum RoutingMode {
PARALLEL
SHARED
EXCLUSIVE
POST_MAIN
}
enum StageStatus {
@ -1846,7 +1845,6 @@ model Pipeline {
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
tracks Track[]
routingRules RoutingRule[]
@@index([programId])
@@index([status])
@ -1871,8 +1869,6 @@ model Track {
stages Stage[]
projectStageStates ProjectStageState[]
specialAward SpecialAward?
routingRulesAsSource RoutingRule[] @relation("RoutingSourceTrack")
routingRulesAsDestination RoutingRule[] @relation("RoutingDestinationTrack")
@@unique([pipelineId, slug])
@@unique([pipelineId, sortOrder])
@ -1969,30 +1965,6 @@ model ProjectStageState {
@@index([projectId, trackId])
}
model RoutingRule {
id String @id @default(cuid())
pipelineId String
name String
scope String @default("global") // global, track, stage
sourceTrackId String?
destinationTrackId String
destinationStageId String?
predicateJson Json @db.JsonB // { field, operator, value } or compound
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
sourceTrack Track? @relation("RoutingSourceTrack", fields: [sourceTrackId], references: [id], onDelete: SetNull)
destinationTrack Track @relation("RoutingDestinationTrack", fields: [destinationTrackId], references: [id], onDelete: Cascade)
@@index([pipelineId])
@@index([priority])
@@index([isActive])
}
model Cohort {
id String @id @default(cuid())

View File

@ -50,9 +50,14 @@ const issueMap: Record<string, OceanIssue> = {
'Other': OceanIssue.OTHER,
}
function normalizeSpaces(s: string): string {
// Replace non-breaking spaces (U+00A0) and other whitespace variants with regular spaces
return s.replace(/\u00A0/g, ' ')
}
function mapCategory(raw: string | undefined): CompetitionCategory | null {
if (!raw) return null
const trimmed = raw.trim()
const trimmed = normalizeSpaces(raw.trim())
for (const [prefix, value] of Object.entries(categoryMap)) {
if (trimmed.startsWith(prefix)) return value
}
@ -61,7 +66,7 @@ function mapCategory(raw: string | undefined): CompetitionCategory | null {
function mapIssue(raw: string | undefined): OceanIssue | null {
if (!raw) return null
const trimmed = raw.trim()
const trimmed = normalizeSpaces(raw.trim())
for (const [prefix, value] of Object.entries(issueMap)) {
if (trimmed.startsWith(prefix)) return value
}
@ -76,17 +81,11 @@ function parseFoundedDate(raw: string | undefined): Date | null {
return isNaN(d.getTime()) ? null : d
}
function isValidEntry(row: Record<string, string>): boolean {
const status = (row['Application status'] || '').trim().toLowerCase()
if (status === 'ignore' || status === 'doublon') return false
function isEmptyRow(row: Record<string, string>): boolean {
const name = (row['Full name'] || '').trim()
if (name.length <= 2) return false // skip test entries
const email = (row['E-mail'] || '').trim()
if (!email || !email.includes('@')) return false
return true
const project = (row["Project's name"] || '').trim()
return !name && !email && !project
}
// =============================================================================
@ -476,7 +475,7 @@ async function main() {
name: 'Ocean Innovation Award',
slug: 'innovation-award',
kind: TrackKind.AWARD,
routingMode: RoutingMode.PARALLEL,
routingMode: RoutingMode.SHARED,
decisionMode: DecisionMode.JURY_VOTE,
sortOrder: 1,
settingsJson: { description: 'Award for most innovative ocean technology' },
@ -506,16 +505,16 @@ async function main() {
name: "People's Choice",
slug: 'peoples-choice',
kind: TrackKind.SHOWCASE,
routingMode: RoutingMode.POST_MAIN,
routingMode: RoutingMode.SHARED,
sortOrder: 3,
settingsJson: { description: 'Public audience voting for fan favorite' },
},
})
console.log(` ✓ Main Competition (MAIN)`)
console.log(` ✓ Ocean Innovation Award (AWARD, PARALLEL)`)
console.log(` ✓ Ocean Innovation Award (AWARD, SHARED)`)
console.log(` ✓ Ocean Impact Award (AWARD, EXCLUSIVE)`)
console.log(` ✓ People's Choice (SHOWCASE, POST_MAIN)`)
console.log(` ✓ People's Choice (SHOWCASE, SHARED)`)
// ==========================================================================
// 9. Stages
@ -814,21 +813,9 @@ async function main() {
console.log(` Raw CSV rows: ${records.length}`)
// Filter and deduplicate
const seenEmails = new Set<string>()
const validRecords: Record<string, string>[] = []
for (const row of records) {
if (!isValidEntry(row)) continue
const email = (row['E-mail'] || '').trim().toLowerCase()
if (seenEmails.has(email)) continue
seenEmails.add(email)
validRecords.push(row)
}
console.log(` Valid entries after filtering: ${validRecords.length}`)
// Skip only completely empty rows (no name, no email, no project)
const validRecords = records.filter((row: Record<string, string>) => !isEmptyRow(row))
console.log(` Entries to seed: ${validRecords.length}`)
// Create applicant users and projects
console.log('\n🚀 Creating applicant users and projects...')
@ -836,7 +823,9 @@ async function main() {
const intakeStage = mainStages[0] // INTAKE - CLOSED
const filterStage = mainStages[1] // FILTER - ACTIVE
for (const row of validRecords) {
let skippedNoEmail = 0
for (let rowIdx = 0; rowIdx < validRecords.length; rowIdx++) {
const row = validRecords[rowIdx]
const email = (row['E-mail'] || '').trim().toLowerCase()
const name = (row['Full name'] || '').trim()
const phone = (row['Téléphone'] || '').trim() || null
@ -855,7 +844,14 @@ async function main() {
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
const foundedAt = parseFoundedDate(row['Date of creation'])
// Create or get applicant user
// Skip rows with no usable email (can't create user without one)
if (!email || !email.includes('@')) {
skippedNoEmail++
console.log(` ⚠ Row ${rowIdx + 2}: skipped (no valid email)`)
continue
}
// Create or get applicant user (upsert handles duplicate emails)
const user = await prisma.user.upsert({
where: { email },
update: {
@ -864,7 +860,7 @@ async function main() {
},
create: {
email,
name,
name: name || `Applicant ${rowIdx + 1}`,
role: UserRole.APPLICANT,
status: UserStatus.NONE,
phoneNumber: phone,
@ -930,6 +926,9 @@ async function main() {
}
console.log(` ✓ Created ${projectCount} projects with stage states`)
if (skippedNoEmail > 0) {
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
}
}
// ==========================================================================
@ -998,58 +997,7 @@ async function main() {
console.log(' ✓ Ocean Impact Award → impact-award track')
// ==========================================================================
// 14. Routing Rules
// ==========================================================================
console.log('\n🔀 Creating routing rules...')
const existingTechRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Tech Innovation to Innovation Award' },
})
if (!existingTechRule) {
await prisma.routingRule.create({
data: {
pipelineId: pipeline.id,
name: 'Route Tech Innovation to Innovation Award',
scope: 'global',
destinationTrackId: innovationTrack.id,
predicateJson: {
field: 'oceanIssue',
operator: 'eq',
value: 'TECHNOLOGY_INNOVATION',
},
priority: 10,
isActive: true,
},
})
}
const existingImpactRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Community Impact to Impact Award' },
})
if (!existingImpactRule) {
await prisma.routingRule.create({
data: {
pipelineId: pipeline.id,
name: 'Route Community Impact to Impact Award',
scope: 'global',
destinationTrackId: impactTrack.id,
predicateJson: {
or: [
{ field: 'oceanIssue', operator: 'eq', value: 'COMMUNITY_CAPACITY' },
{ field: 'oceanIssue', operator: 'eq', value: 'HABITAT_RESTORATION' },
],
},
priority: 5,
isActive: true,
},
})
}
console.log(' ✓ Tech Innovation → Innovation Award (PARALLEL)')
console.log(' ✓ Community Impact → Impact Award (EXCLUSIVE)')
// ==========================================================================
// 15. Notification Email Settings
// 14. Notification Email Settings
// ==========================================================================
console.log('\n🔔 Creating notification email settings...')

View File

@ -41,7 +41,6 @@ import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor'
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
@ -601,16 +600,6 @@ export default function PipelineDetailPage() {
</Card>
</div>
{/* Routing Rules (only if multiple tracks) */}
{hasMultipleTracks && (
<div>
<RoutingRulesEditor
pipelineId={pipelineId}
tracks={trackOptionsForEditors}
/>
</div>
)}
{/* Award Governance (only if award tracks exist) */}
{hasAwardTracks && (
<div>

View File

@ -58,7 +58,7 @@ export default function EditPipelineWizardPage() {
slug: track.slug,
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
sortOrder: track.sortOrder,
routingMode: track.routingMode as 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null,
routingMode: track.routingMode as 'SHARED' | 'EXCLUSIVE' | null,
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
stages: track.stages.map(s => ({
id: s.id,

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" />
)}
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',

View File

@ -7,7 +7,7 @@ type TrackInput = {
slug: string
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
sortOrder: number
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
routingMode: 'SHARED' | 'EXCLUSIVE' | null
decisionMode:
| 'JURY_VOTE'
| 'AWARD_MASTER_DECISION'

View File

@ -110,7 +110,7 @@ export function defaultAwardTrack(index: number): WizardTrackConfig {
slug: slugify(name),
kind: 'AWARD',
sortOrder: index + 1,
routingModeDefault: 'PARALLEL',
routingModeDefault: 'SHARED',
decisionMode: 'JURY_VOTE',
stages: [
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 0, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
@ -125,7 +125,6 @@ export function defaultNotificationConfig(): Record<string, boolean> {
'stage.transitioned': true,
'filtering.completed': true,
'assignment.generated': true,
'routing.executed': true,
'live.cursor.updated': true,
'cohort.window.changed': true,
'decision.overridden': true,

View File

@ -37,7 +37,7 @@ import { dashboardRouter } from './dashboard'
// Round redesign Phase 2 routers
import { pipelineRouter } from './pipeline'
import { stageRouter } from './stage'
import { routingRouter } from './routing'
import { stageFilteringRouter } from './stageFiltering'
import { stageAssignmentRouter } from './stageAssignment'
import { cohortRouter } from './cohort'
@ -87,7 +87,6 @@ export const appRouter = router({
// Round redesign Phase 2 routers
pipeline: pipelineRouter,
stage: stageRouter,
routing: routingRouter,
stageFiltering: stageFilteringRouter,
stageAssignment: stageAssignmentRouter,
cohort: cohortRouter,

View File

@ -14,7 +14,7 @@ export const awardRouter = router({
pipelineId: z.string(),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
routingMode: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingMode: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
settingsJson: z.record(z.unknown()).optional(),
awardConfig: z.object({

View File

@ -186,10 +186,6 @@ export const pipelineRouter = router({
},
},
},
routingRules: {
where: { isActive: true },
orderBy: { priority: 'desc' },
},
},
})
}),
@ -209,7 +205,7 @@ export const pipelineRouter = router({
_count: { select: { stages: true, projectStageStates: true } },
},
},
_count: { select: { tracks: true, routingRules: true } },
_count: { select: { tracks: true } },
},
})
@ -244,7 +240,7 @@ export const pipelineRouter = router({
where: { programId: input.programId },
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { tracks: true, routingRules: true } },
_count: { select: { tracks: true } },
},
})
}),
@ -327,7 +323,7 @@ export const pipelineRouter = router({
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
sortOrder: z.number().int().min(0),
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
stages: z.array(
z.object({
@ -543,13 +539,6 @@ export const pipelineRouter = router({
},
},
},
routingRules: {
orderBy: { priority: 'desc' },
include: {
sourceTrack: { select: { id: true, name: true } },
destinationTrack: { select: { id: true, name: true } },
},
},
},
})
@ -573,7 +562,7 @@ export const pipelineRouter = router({
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
sortOrder: z.number().int().min(0),
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
stages: z.array(
z.object({
@ -866,10 +855,6 @@ export const pipelineRouter = router({
tracks: {
include: { stages: { orderBy: { sortOrder: 'asc' } } },
},
routingRules: {
where: { isActive: true },
orderBy: { priority: 'desc' },
},
},
})
@ -895,26 +880,8 @@ export const pipelineRouter = router({
// Simulate: for each project, determine which track/stage it would land in
const mainTrack = pipeline.tracks.find((t) => t.kind === 'MAIN')
const simulations = projects.map((project) => {
// Default: route to first stage of MAIN track
let targetTrack = mainTrack
let targetStage = mainTrack?.stages[0] ?? null
// Check routing rules (highest priority first)
for (const rule of pipeline.routingRules) {
const predicate = rule.predicateJson as Record<string, unknown>
if (predicate && evaluateSimplePredicate(predicate, project)) {
const destTrack = pipeline.tracks.find(
(t) => t.id === rule.destinationTrackId
)
if (destTrack) {
targetTrack = destTrack
targetStage = rule.destinationStageId
? destTrack.stages.find((s) => s.id === rule.destinationStageId) ?? destTrack.stages[0]
: destTrack.stages[0]
break
}
}
}
const targetTrack = mainTrack
const targetStage = mainTrack?.stages[0] ?? null
return {
projectId: project.id,
@ -1122,50 +1089,3 @@ export const pipelineRouter = router({
})
/**
* Simple predicate evaluator for simulation.
* Supports basic field matching on project data.
*/
function evaluateSimplePredicate(
predicate: Record<string, unknown>,
project: { tags: string[]; status: string; metadataJson: unknown }
): boolean {
const { field, operator, value } = predicate as {
field?: string
operator?: string
value?: unknown
}
if (!field || !operator) return false
let fieldValue: unknown
if (field === 'tags') {
fieldValue = project.tags
} else if (field === 'status') {
fieldValue = project.status
} else {
// Check metadataJson
const meta = (project.metadataJson as Record<string, unknown>) ?? {}
fieldValue = meta[field]
}
switch (operator) {
case 'equals':
return fieldValue === value
case 'contains':
if (Array.isArray(fieldValue)) return fieldValue.includes(value)
if (typeof fieldValue === 'string' && typeof value === 'string')
return fieldValue.includes(value)
return false
case 'in':
if (Array.isArray(value)) return value.includes(fieldValue)
return false
case 'hasAny':
if (Array.isArray(fieldValue) && Array.isArray(value))
return fieldValue.some((v) => value.includes(v))
return false
default:
return false
}
}

View File

@ -1,519 +0,0 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import {
previewRouting,
evaluateRoutingRules,
executeRouting,
} from '@/server/services/routing-engine'
export const routingRouter = router({
/**
* Preview routing: show where projects would land without executing.
* Delegates to routing-engine service for proper predicate evaluation.
*/
preview: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
const results = await previewRouting(
input.projectIds,
input.pipelineId,
ctx.prisma
)
return {
pipelineId: input.pipelineId,
totalProjects: results.length,
results: results.map((r) => ({
projectId: r.projectId,
projectTitle: r.projectTitle,
matchedRuleId: r.matchedRule?.ruleId ?? null,
matchedRuleName: r.matchedRule?.ruleName ?? null,
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
targetTrackName: null as string | null,
targetStageId: r.matchedRule?.destinationStageId ?? null,
targetStageName: null as string | null,
routingMode: r.matchedRule?.routingMode ?? null,
reason: r.reason,
})),
}
}),
/**
* Execute routing: evaluate rules and move projects into tracks/stages.
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
*/
execute: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
// Verify pipeline is ACTIVE
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
where: { id: input.pipelineId },
})
if (pipeline.status !== 'ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Pipeline must be ACTIVE to route projects',
})
}
// Load projects to get their current active stage states
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
select: {
id: true,
title: true,
projectStageStates: {
where: { exitedAt: null },
select: { stageId: true },
take: 1,
},
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No matching projects found',
})
}
let routedCount = 0
let skippedCount = 0
const errors: Array<{ projectId: string; error: string }> = []
for (const project of projects) {
const activePSS = project.projectStageStates[0]
if (!activePSS) {
skippedCount++
continue
}
// Evaluate routing rules using the service
const matchedRule = await evaluateRoutingRules(
project.id,
activePSS.stageId,
input.pipelineId,
ctx.prisma
)
if (!matchedRule) {
skippedCount++
continue
}
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
const result = await executeRouting(
project.id,
matchedRule,
ctx.user.id,
ctx.prisma
)
if (result.success) {
routedCount++
} else {
skippedCount++
if (result.errors?.length) {
errors.push({ projectId: project.id, error: result.errors[0] })
}
}
}
// Record batch-level audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ROUTING_EXECUTED',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
projectCount: projects.length,
routedCount,
skippedCount,
errors: errors.length > 0 ? errors : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { routedCount, skippedCount, totalProjects: projects.length }
}),
/**
* List routing rules for a pipeline
*/
listRules: adminProcedure
.input(z.object({ pipelineId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
include: {
sourceTrack: { select: { id: true, name: true } },
destinationTrack: { select: { id: true, name: true } },
},
})
}),
/**
* Create or update a routing rule
*/
upsertRule: adminProcedure
.input(
z.object({
id: z.string().optional(), // If provided, update existing
pipelineId: z.string(),
name: z.string().min(1).max(255),
scope: z.enum(['global', 'track', 'stage']).default('global'),
sourceTrackId: z.string().optional().nullable(),
destinationTrackId: z.string(),
destinationStageId: z.string().optional().nullable(),
predicateJson: z.record(z.unknown()),
priority: z.number().int().min(0).max(1000).default(0),
isActive: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { id, predicateJson, ...data } = input
// Verify destination track exists in this pipeline
const destTrack = await ctx.prisma.track.findFirst({
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
})
if (!destTrack) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Destination track must belong to the same pipeline',
})
}
if (id) {
// Update existing rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id },
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'RoutingRule',
entityId: id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
} else {
// Create new rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.routingRule.create({
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'RoutingRule',
entityId: created.id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return rule
}
}),
/**
* Delete a routing rule
*/
deleteRule: adminProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, pipelineId: true },
})
await ctx.prisma.$transaction(async (tx) => {
await tx.routingRule.delete({
where: { id: input.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Reorder routing rules by priority (highest first)
*/
reorderRules: adminProcedure
.input(
z.object({
pipelineId: z.string(),
orderedIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const rules = await ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true },
})
const ruleIds = new Set(rules.map((rule) => rule.id))
for (const id of input.orderedIds) {
if (!ruleIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Routing rule ${id} does not belong to this pipeline`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
const maxPriority = input.orderedIds.length
await Promise.all(
input.orderedIds.map((id, index) =>
tx.routingRule.update({
where: { id },
data: {
priority: maxPriority - index,
},
})
)
)
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
action: 'ROUTING_RULES_REORDERED',
ruleCount: input.orderedIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Toggle a routing rule on/off
*/
toggleRule: adminProcedure
.input(
z.object({
id: z.string(),
isActive: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id: input.id },
data: { isActive: input.isActive },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { isActive: input.isActive, name: updated.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
}),
/**
* Parse natural language into a routing rule predicate using AI
*/
parseNaturalLanguageRule: adminProcedure
.input(
z.object({
text: z.string().min(1).max(500),
pipelineId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'OpenAI is not configured. Go to Settings to set up the API key.',
})
}
// Load pipeline tracks for context
const tracks = await ctx.prisma.track.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' },
})
const trackNames = tracks.map((t) => t.name).join(', ')
const model = await getConfiguredModel()
const systemPrompt = `You are a routing rule parser for a project management pipeline.
Convert the user's natural language description into a structured predicate JSON.
Available fields:
- competitionCategory: The project's competition category (string values like "STARTUP", "BUSINESS_CONCEPT")
- oceanIssue: The ocean issue the project addresses (string)
- country: The project's country of origin (string)
- geographicZone: The geographic zone (string)
- wantsMentorship: Whether the project wants mentorship (boolean: true/false)
- tags: Project tags (array of strings)
Available operators:
- eq: equals (exact match)
- neq: not equals
- in: value is in a list
- contains: string contains substring
- gt: greater than (numeric)
- lt: less than (numeric)
Predicate format:
- Simple condition: { "field": "<field>", "operator": "<op>", "value": "<value>" }
- Compound (AND): { "logic": "and", "conditions": [<condition>, ...] }
- Compound (OR): { "logic": "or", "conditions": [<condition>, ...] }
For boolean fields (wantsMentorship), use value: true or value: false (not strings).
For "in" operator, value should be an array: ["VALUE1", "VALUE2"].
Pipeline tracks: ${trackNames || 'None configured yet'}
Return a JSON object with two keys:
- "predicate": the predicate JSON object
- "explanation": a brief human-readable explanation of what the rule matches
Example input: "projects from France or Monaco that are startups"
Example output:
{
"predicate": {
"logic": "and",
"conditions": [
{ "field": "country", "operator": "in", "value": ["France", "Monaco"] },
{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }
]
},
"explanation": "Matches projects from France or Monaco with competition category STARTUP"
}`
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: input.text },
],
maxTokens: 1000,
temperature: 0.1,
jsonMode: true,
})
const response = await openai.chat.completions.create(params)
const content = response.choices[0]?.message?.content
if (!content) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned an empty response',
})
}
// Log AI usage
const tokenUsage = extractTokenUsage(response)
await logAIUsage({
userId: ctx.user.id,
action: 'ROUTING',
entityType: 'Pipeline',
entityId: input.pipelineId,
model,
...tokenUsage,
itemsProcessed: 1,
status: 'SUCCESS',
detailsJson: { input: input.text },
})
// Parse the response
let parsed: { predicate: Record<string, unknown>; explanation: string }
try {
parsed = JSON.parse(content) as { predicate: Record<string, unknown>; explanation: string }
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned invalid JSON. Try rephrasing your rule.',
})
}
if (!parsed.predicate || typeof parsed.predicate !== 'object') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI response missing predicate. Try rephrasing your rule.',
})
}
return {
predicateJson: parsed.predicate,
explanation: parsed.explanation || 'Parsed routing rule',
}
}),
})

View File

@ -1,505 +0,0 @@
/**
* Routing Engine Service
*
* Evaluates routing rules against projects and executes routing decisions
* to move projects between tracks in a pipeline. Supports three routing modes:
*
* - PARALLEL: Keep the project in the current track AND add it to the destination track
* - EXCLUSIVE: Exit the project from the current track and move to the destination track
* - POST_MAIN: Route to destination only after the main track gate is passed
*
* Predicate evaluation supports operators: eq, neq, in, contains, gt, lt
* Compound predicates: and, or
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
interface PredicateLeaf {
field: string
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt'
value: unknown
}
interface PredicateCompound {
logic: 'and' | 'or'
conditions: PredicateNode[]
}
type PredicateNode = PredicateLeaf | PredicateCompound
export interface MatchedRule {
ruleId: string
ruleName: string
destinationTrackId: string
destinationStageId: string | null
routingMode: string
priority: number
}
export interface RoutingPreviewItem {
projectId: string
projectTitle: string
matchedRule: MatchedRule | null
reason: string
}
export interface RoutingExecutionResult {
success: boolean
projectStageStateId: string | null
errors?: string[]
}
// ─── Predicate Evaluation ───────────────────────────────────────────────────
function isCompoundPredicate(node: unknown): node is PredicateCompound {
return (
typeof node === 'object' &&
node !== null &&
'logic' in node &&
'conditions' in node
)
}
function isLeafPredicate(node: unknown): node is PredicateLeaf {
return (
typeof node === 'object' &&
node !== null &&
'field' in node &&
'operator' in node
)
}
function evaluateLeaf(
leaf: PredicateLeaf,
context: Record<string, unknown>
): boolean {
const fieldValue = resolveField(context, leaf.field)
switch (leaf.operator) {
case 'eq':
return fieldValue === leaf.value
case 'neq':
return fieldValue !== leaf.value
case 'in': {
if (!Array.isArray(leaf.value)) return false
return leaf.value.includes(fieldValue)
}
case 'contains': {
if (typeof fieldValue === 'string' && typeof leaf.value === 'string') {
return fieldValue.toLowerCase().includes(leaf.value.toLowerCase())
}
if (Array.isArray(fieldValue)) {
return fieldValue.includes(leaf.value)
}
return false
}
case 'gt':
return Number(fieldValue) > Number(leaf.value)
case 'lt':
return Number(fieldValue) < Number(leaf.value)
default:
return false
}
}
/**
* Resolve a dot-notation field path from a context object.
* E.g. "project.country" resolves context.project.country
*/
function resolveField(
context: Record<string, unknown>,
fieldPath: string
): unknown {
const parts = fieldPath.split('.')
let current: unknown = context
for (const part of parts) {
if (current === null || current === undefined) return undefined
if (typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[part]
}
return current
}
function evaluatePredicate(
node: PredicateNode,
context: Record<string, unknown>
): boolean {
if (isCompoundPredicate(node)) {
const results = node.conditions.map((child) =>
evaluatePredicate(child, context)
)
return node.logic === 'and'
? results.every(Boolean)
: results.some(Boolean)
}
if (isLeafPredicate(node)) {
return evaluateLeaf(node, context)
}
// Unknown node type, fail closed
return false
}
// ─── Build Project Context ──────────────────────────────────────────────────
async function buildProjectContext(
projectId: string,
currentStageId: string,
prisma: PrismaClient | any
): Promise<Record<string, unknown>> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: { select: { fileType: true, fileName: true } },
projectTags: { include: { tag: true } },
filteringResults: {
where: { stageId: currentStageId },
take: 1,
orderBy: { createdAt: 'desc' as const },
},
assignments: {
where: { stageId: currentStageId },
include: { evaluation: true },
},
projectStageStates: {
where: { stageId: currentStageId, exitedAt: null },
take: 1,
},
},
})
if (!project) return {}
const evaluations = project.assignments
.map((a: any) => a.evaluation)
.filter(Boolean)
const submittedEvals = evaluations.filter(
(e: any) => e.status === 'SUBMITTED'
)
const avgScore =
submittedEvals.length > 0
? submittedEvals.reduce(
(sum: number, e: any) => sum + (e.globalScore ?? 0),
0
) / submittedEvals.length
: 0
const filteringResult = project.filteringResults[0] ?? null
const currentPSS = project.projectStageStates[0] ?? null
return {
project: {
id: project.id,
title: project.title,
status: project.status,
country: project.country,
competitionCategory: project.competitionCategory,
oceanIssue: project.oceanIssue,
tags: project.tags,
wantsMentorship: project.wantsMentorship,
fileCount: project.files.length,
},
tags: project.projectTags.map((pt: any) => pt.tag.name),
evaluation: {
count: evaluations.length,
submittedCount: submittedEvals.length,
averageScore: avgScore,
},
filtering: {
outcome: filteringResult?.outcome ?? null,
finalOutcome: filteringResult?.finalOutcome ?? null,
},
state: currentPSS?.state ?? null,
}
}
// ─── Evaluate Routing Rules ─────────────────────────────────────────────────
/**
* Load active routing rules for a pipeline, evaluate predicates against a
* project's context, and return the first matching rule (by priority, lowest first).
*/
export async function evaluateRoutingRules(
projectId: string,
currentStageId: string,
pipelineId: string,
prisma: PrismaClient | any
): Promise<MatchedRule | null> {
const rules = await prisma.routingRule.findMany({
where: {
pipelineId,
isActive: true,
},
include: {
destinationTrack: true,
sourceTrack: true,
},
orderBy: { priority: 'asc' as const },
})
if (rules.length === 0) return null
const context = await buildProjectContext(projectId, currentStageId, prisma)
for (const rule of rules) {
// If rule has a sourceTrackId, check that the project is in that track
if (rule.sourceTrackId) {
const inSourceTrack = await prisma.projectStageState.findFirst({
where: {
projectId,
trackId: rule.sourceTrackId,
exitedAt: null,
},
})
if (!inSourceTrack) continue
}
const predicateJson = rule.predicateJson as unknown as PredicateNode
if (evaluatePredicate(predicateJson, context)) {
return {
ruleId: rule.id,
ruleName: rule.name,
destinationTrackId: rule.destinationTrackId,
destinationStageId: rule.destinationStageId ?? null,
routingMode: rule.destinationTrack.routingMode ?? 'EXCLUSIVE',
priority: rule.priority,
}
}
}
return null
}
// ─── Execute Routing ────────────────────────────────────────────────────────
/**
* Execute a routing decision for a project based on the matched rule.
*
* PARALLEL mode: Keep the project in its current track, add a new PSS in the
* destination track's first stage (or specified destination stage).
* EXCLUSIVE mode: Exit the current PSS and create a new PSS in the destination.
* POST_MAIN mode: Validate that the project PASSED the main track gate before routing.
*/
export async function executeRouting(
projectId: string,
matchedRule: MatchedRule,
actorId: string,
prisma: PrismaClient | any
): Promise<RoutingExecutionResult> {
try {
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
// Determine destination stage
let destinationStageId = matchedRule.destinationStageId
if (!destinationStageId) {
// Find the first stage in the destination track (by sortOrder)
const firstStage = await tx.stage.findFirst({
where: { trackId: matchedRule.destinationTrackId },
orderBy: { sortOrder: 'asc' as const },
})
if (!firstStage) {
throw new Error(
`No stages found in destination track ${matchedRule.destinationTrackId}`
)
}
destinationStageId = firstStage.id
}
// Mode-specific logic
if (matchedRule.routingMode === 'POST_MAIN') {
// Validate that the project has passed the main track gate
const mainTrack = await tx.track.findFirst({
where: {
pipeline: {
tracks: {
some: { id: matchedRule.destinationTrackId },
},
},
kind: 'MAIN',
},
})
if (mainTrack) {
const mainPSS = await tx.projectStageState.findFirst({
where: {
projectId,
trackId: mainTrack.id,
state: { in: ['PASSED', 'COMPLETED'] },
},
orderBy: { exitedAt: 'desc' as const },
})
if (!mainPSS) {
throw new Error(
'POST_MAIN routing requires the project to have passed the main track gate'
)
}
}
}
if (matchedRule.routingMode === 'EXCLUSIVE') {
// Exit all active PSS for this project in any track of the same pipeline
const activePSSRecords = await tx.projectStageState.findMany({
where: {
projectId,
exitedAt: null,
},
})
for (const pss of activePSSRecords) {
await tx.projectStageState.update({
where: { id: pss.id },
data: {
exitedAt: now,
state: 'ROUTED',
},
})
}
}
// Create PSS in destination track/stage
const destPSS = await tx.projectStageState.upsert({
where: {
projectId_trackId_stageId: {
projectId,
trackId: matchedRule.destinationTrackId,
stageId: destinationStageId,
},
},
create: {
projectId,
trackId: matchedRule.destinationTrackId,
stageId: destinationStageId,
state: 'PENDING',
enteredAt: now,
},
update: {
state: 'PENDING',
enteredAt: now,
exitedAt: null,
},
})
// Log DecisionAuditLog
await tx.decisionAuditLog.create({
data: {
eventType: 'routing.executed',
entityType: 'ProjectStageState',
entityId: destPSS.id,
actorId,
detailsJson: {
projectId,
ruleId: matchedRule.ruleId,
ruleName: matchedRule.ruleName,
routingMode: matchedRule.routingMode,
destinationTrackId: matchedRule.destinationTrackId,
destinationStageId,
},
snapshotJson: {
destPSSId: destPSS.id,
timestamp: now.toISOString(),
},
},
})
// AuditLog
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUTING_EXECUTED',
entityType: 'RoutingRule',
entityId: matchedRule.ruleId,
detailsJson: {
projectId,
routingMode: matchedRule.routingMode,
destinationTrackId: matchedRule.destinationTrackId,
destinationStageId,
},
})
return destPSS
})
return {
success: true,
projectStageStateId: result.id,
}
} catch (error) {
console.error('[RoutingEngine] Routing execution failed:', error)
return {
success: false,
projectStageStateId: null,
errors: [
error instanceof Error
? error.message
: 'Unknown error during routing execution',
],
}
}
}
// ─── Preview Routing ────────────────────────────────────────────────────────
/**
* Dry-run evaluation of routing rules for a batch of projects.
* Does not modify any data.
*/
export async function previewRouting(
projectIds: string[],
pipelineId: string,
prisma: PrismaClient | any
): Promise<RoutingPreviewItem[]> {
const preview: RoutingPreviewItem[] = []
// Load projects with their current stage states
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
projectStageStates: {
where: { exitedAt: null },
select: { stageId: true, trackId: true, state: true },
},
},
})
for (const project of projects) {
const activePSS = project.projectStageStates[0]
if (!activePSS) {
preview.push({
projectId: project.id,
projectTitle: project.title,
matchedRule: null,
reason: 'No active stage state found',
})
continue
}
const matchedRule = await evaluateRoutingRules(
project.id,
activePSS.stageId,
pipelineId,
prisma
)
preview.push({
projectId: project.id,
projectTitle: project.title,
matchedRule,
reason: matchedRule
? `Matched rule "${matchedRule.ruleName}" (priority ${matchedRule.priority})`
: 'No routing rules matched',
})
}
return preview
}

View File

@ -261,6 +261,34 @@ export async function runStageFiltering(
)
const aiRules = rules.filter((r: any) => r.ruleType === 'AI_SCREENING')
// ── Built-in: Duplicate submission detection ──────────────────────────────
// Group projects by submitter email to detect duplicate submissions.
// Duplicates are ALWAYS flagged for admin review (never auto-rejected).
const duplicateProjectIds = new Set<string>()
const emailToProjects = new Map<string, Array<{ id: string; title: string }>>()
for (const project of projects) {
const email = (project.submittedByEmail ?? '').toLowerCase().trim()
if (!email) continue
if (!emailToProjects.has(email)) emailToProjects.set(email, [])
emailToProjects.get(email)!.push({ id: project.id, title: project.title })
}
const duplicateGroups: Map<string, string[]> = new Map() // projectId → sibling ids
emailToProjects.forEach((group, _email) => {
if (group.length <= 1) return
const ids = group.map((p) => p.id)
for (const p of group) {
duplicateProjectIds.add(p.id)
duplicateGroups.set(p.id, ids.filter((id) => id !== p.id))
}
})
if (duplicateProjectIds.size > 0) {
console.log(`[Stage Filtering] Detected ${duplicateProjectIds.size} projects in duplicate groups`)
}
// ── End duplicate detection ───────────────────────────────────────────────
let passed = 0
let rejected = 0
let manualQueue = 0
@ -271,6 +299,20 @@ export async function runStageFiltering(
let deterministicPassed = true
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
// 0. Check for duplicate submissions (always FLAG, never auto-reject)
if (duplicateProjectIds.has(project.id)) {
const siblingIds = duplicateGroups.get(project.id) ?? []
ruleResults.push({
ruleId: '__duplicate_check',
ruleName: 'Duplicate Submission Check',
ruleType: 'DUPLICATE_CHECK',
passed: false,
action: 'FLAG',
reasoning: `Duplicate submission detected: same applicant email submitted ${siblingIds.length + 1} project(s). Sibling project IDs: ${siblingIds.join(', ')}. Admin must review and decide which to keep.`,
})
deterministicOutcome = 'FLAGGED'
}
// 1. Run deterministic rules
for (const rule of deterministicRules) {
const config = rule.configJson as unknown as RuleConfig
@ -312,11 +354,12 @@ export async function runStageFiltering(
}
}
// 2. AI screening (only if deterministic passed)
// 2. AI screening (run if deterministic passed, OR if duplicate—so AI can recommend which to keep)
const isDuplicate = duplicateProjectIds.has(project.id)
let aiScreeningJson: Record<string, unknown> | null = null
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
if (deterministicPassed && aiRules.length > 0) {
if ((deterministicPassed || isDuplicate) && aiRules.length > 0) {
// Build a simplified AI screening result using the existing AI criteria
// In production this would call OpenAI via the ai-filtering service
const aiRule = aiRules[0]
@ -337,12 +380,25 @@ export async function runStageFiltering(
: 'Insufficient project data for AI screening',
}
// Attach duplicate metadata so admin can see sibling projects
if (isDuplicate) {
const siblingIds = duplicateGroups.get(project.id) ?? []
aiScreeningJson.isDuplicate = true
aiScreeningJson.siblingProjectIds = siblingIds
aiScreeningJson.duplicateNote =
`This project shares a submitter email with ${siblingIds.length} other project(s). ` +
'AI screening should compare these and recommend which to keep.'
}
const banded = bandByConfidence({
confidence,
meetsAllCriteria: hasMinimalData,
})
// For non-duplicate projects, use AI banding; for duplicates, keep FLAGGED
if (!isDuplicate) {
finalOutcome = banded.outcome
}
ruleResults.push({
ruleId: aiRule.id,
@ -354,6 +410,12 @@ export async function runStageFiltering(
})
}
// Duplicate submissions must ALWAYS be flagged for admin review,
// even if other rules would auto-reject them.
if (duplicateProjectIds.has(project.id) && finalOutcome === 'FILTERED_OUT') {
finalOutcome = 'FLAGGED'
}
await prisma.filteringResult.upsert({
where: {
stageId_projectId: {

View File

@ -8,7 +8,7 @@
*
* Event types follow a dotted convention:
* stage.transitioned, filtering.completed, assignment.generated,
* routing.executed, live.cursor_updated, decision.overridden
* live.cursor_updated, decision.overridden
*/
import type { PrismaClient, Prisma } from '@prisma/client'
@ -32,7 +32,6 @@ const EVENT_TYPES = {
STAGE_TRANSITIONED: 'stage.transitioned',
FILTERING_COMPLETED: 'filtering.completed',
ASSIGNMENT_GENERATED: 'assignment.generated',
ROUTING_EXECUTED: 'routing.executed',
CURSOR_UPDATED: 'live.cursor_updated',
DECISION_OVERRIDDEN: 'decision.overridden',
} as const
@ -41,7 +40,6 @@ const EVENT_TITLES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
[EVENT_TYPES.ROUTING_EXECUTED]: 'Routing Executed',
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
}
@ -50,7 +48,6 @@ const EVENT_ICONS: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
[EVENT_TYPES.ROUTING_EXECUTED]: 'GitBranch',
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
}
@ -59,7 +56,6 @@ const EVENT_PRIORITIES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
[EVENT_TYPES.ROUTING_EXECUTED]: 'normal',
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
}
@ -220,7 +216,6 @@ async function resolveRecipients(
case EVENT_TYPES.STAGE_TRANSITIONED:
case EVENT_TYPES.FILTERING_COMPLETED:
case EVENT_TYPES.ASSIGNMENT_GENERATED:
case EVENT_TYPES.ROUTING_EXECUTED:
case EVENT_TYPES.DECISION_OVERRIDDEN: {
// Notify admins
const admins = await prisma.user.findMany({
@ -311,12 +306,6 @@ function buildNotificationMessage(
return `${count ?? 0} assignments were generated for the stage.`
}
case EVENT_TYPES.ROUTING_EXECUTED: {
const ruleName = details.ruleName as string | undefined
const routingMode = details.routingMode as string | undefined
return `Routing rule "${ruleName ?? 'unknown'}" executed in ${routingMode ?? 'unknown'} mode.`
}
case EVENT_TYPES.CURSOR_UPDATED: {
const projectId = details.projectId as string | undefined
const action = details.action as string | undefined
@ -419,34 +408,6 @@ export async function onAssignmentGenerated(
)
}
/**
* Emit a routing.executed event when a project is routed to a new track.
* Called from routing-engine.ts after executeRouting.
*/
export async function onRoutingExecuted(
ruleId: string,
projectId: string,
ruleName: string,
routingMode: string,
destinationTrackId: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.ROUTING_EXECUTED,
'RoutingRule',
ruleId,
actorId,
{
projectId,
ruleName,
routingMode,
destinationTrackId,
},
prisma
)
}
/**
* Emit a live.cursor_updated event when the live cursor position changes.
* Called from live-control.ts after setActiveProject or jumpToProject.

View File

@ -257,35 +257,6 @@ export async function createTestEvaluationForm(
})
}
// ─── Routing Rule Factory ──────────────────────────────────────────────────
export async function createTestRoutingRule(
pipelineId: string,
destinationTrackId: string,
overrides: Partial<{
name: string
priority: number
predicateJson: Record<string, unknown>
sourceTrackId: string
destinationStageId: string
isActive: boolean
}> = {},
) {
const id = uid('rule')
return prisma.routingRule.create({
data: {
id,
pipelineId,
name: overrides.name ?? `Rule ${id}`,
destinationTrackId,
sourceTrackId: overrides.sourceTrackId ?? null,
destinationStageId: overrides.destinationStageId ?? null,
predicateJson: (overrides.predicateJson ?? { field: 'project.status', operator: 'eq', value: 'SUBMITTED' }) as any,
priority: overrides.priority ?? 0,
isActive: overrides.isActive ?? true,
},
})
}
// ─── Filtering Rule Factory ────────────────────────────────────────────────
@ -397,7 +368,6 @@ export async function cleanupTestData(programId: string, userIds: string[] = [])
await prisma.evaluationDiscussion.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
await prisma.projectStageState.deleteMany({ where: { track: { pipeline: { programId } } } })
await prisma.stageTransition.deleteMany({ where: { fromStage: { track: { pipeline: { programId } } } } })
await prisma.routingRule.deleteMany({ where: { pipeline: { programId } } })
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })

View File

@ -1,99 +0,0 @@
/**
* I-004: Award Exclusive Routing Exclusive Route Removes from Main
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestPipeline,
createTestTrack,
createTestStage,
createTestProject,
createTestPSS,
createTestRoutingRule,
cleanupTestData,
} from '../helpers'
import { evaluateRoutingRules, executeRouting } from '@/server/services/routing-engine'
let programId: string
let userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: 'Award Exclusive Test' })
programId = program.id
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
describe('I-004: Award Exclusive Routing', () => {
it('exclusive routing exits all active PSS and creates new PSS in destination', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const pipeline = await createTestPipeline(programId)
const mainTrack = await createTestTrack(pipeline.id, {
name: 'Main Track',
kind: 'MAIN',
sortOrder: 0,
})
const mainStage = await createTestStage(mainTrack.id, {
name: 'Main Eval',
stageType: 'EVALUATION',
status: 'STAGE_ACTIVE',
})
// Exclusive award track
const exclusiveTrack = await createTestTrack(pipeline.id, {
name: 'Exclusive Award',
kind: 'AWARD',
sortOrder: 1,
routingMode: 'EXCLUSIVE',
})
const exclusiveStage = await createTestStage(exclusiveTrack.id, {
name: 'Exclusive Eval',
stageType: 'EVALUATION',
status: 'STAGE_ACTIVE',
})
// Routing rule with always-matching predicate
await createTestRoutingRule(pipeline.id, exclusiveTrack.id, {
name: 'Exclusive Route',
priority: 0,
predicateJson: { field: 'project.country', operator: 'eq', value: 'Monaco' },
})
// Create project with active PSS in main track
const project = await createTestProject(programId, { country: 'Monaco' })
await createTestPSS(project.id, mainTrack.id, mainStage.id, { state: 'IN_PROGRESS' })
// Evaluate routing
const matchedRule = await evaluateRoutingRules(
project.id, mainStage.id, pipeline.id, prisma
)
expect(matchedRule).not.toBeNull()
expect(matchedRule!.routingMode).toBe('EXCLUSIVE')
// Execute exclusive routing
const routeResult = await executeRouting(project.id, matchedRule!, admin.id, prisma)
expect(routeResult.success).toBe(true)
// Verify: Main track PSS should be exited with state ROUTED
const mainPSS = await prisma.projectStageState.findFirst({
where: { projectId: project.id, trackId: mainTrack.id },
})
expect(mainPSS!.exitedAt).not.toBeNull()
expect(mainPSS!.state).toBe('ROUTED')
// Verify: Exclusive track PSS should be active
const exclusivePSS = await prisma.projectStageState.findFirst({
where: { projectId: project.id, trackId: exclusiveTrack.id, exitedAt: null },
})
expect(exclusivePSS).not.toBeNull()
expect(exclusivePSS!.state).toBe('PENDING')
})
})

View File

@ -1,117 +0,0 @@
/**
* I-003: Transition + Routing Filter Pass Main + Award Parallel
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestPipeline,
createTestTrack,
createTestStage,
createTestTransition,
createTestProject,
createTestPSS,
createTestRoutingRule,
cleanupTestData,
} from '../helpers'
import { executeTransition } from '@/server/services/stage-engine'
import { evaluateRoutingRules, executeRouting } from '@/server/services/routing-engine'
let programId: string
let userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: 'Transition+Routing Test' })
programId = program.id
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
describe('I-003: Transition + Routing — Parallel Award Track', () => {
it('transitions a project then routes it to a parallel award track', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const pipeline = await createTestPipeline(programId)
// Main track: FILTER → EVALUATION
const mainTrack = await createTestTrack(pipeline.id, {
name: 'Main',
kind: 'MAIN',
sortOrder: 0,
})
const filterStage = await createTestStage(mainTrack.id, {
name: 'Filter',
stageType: 'FILTER',
status: 'STAGE_ACTIVE',
sortOrder: 0,
})
const evalStage = await createTestStage(mainTrack.id, {
name: 'Evaluation',
stageType: 'EVALUATION',
status: 'STAGE_ACTIVE',
sortOrder: 1,
})
await createTestTransition(filterStage.id, evalStage.id)
// Award track (PARALLEL)
const awardTrack = await createTestTrack(pipeline.id, {
name: 'Innovation Award',
kind: 'AWARD',
sortOrder: 1,
routingMode: 'PARALLEL',
})
const awardEvalStage = await createTestStage(awardTrack.id, {
name: 'Award Eval',
stageType: 'EVALUATION',
status: 'STAGE_ACTIVE',
sortOrder: 0,
})
// Routing rule: projects from France → Award track (parallel)
await createTestRoutingRule(pipeline.id, awardTrack.id, {
name: 'France → Innovation Award',
priority: 0,
predicateJson: { field: 'project.country', operator: 'eq', value: 'France' },
})
// Create project in filter stage
const project = await createTestProject(programId, { country: 'France' })
await createTestPSS(project.id, mainTrack.id, filterStage.id, { state: 'PENDING' })
// Step 1: Transition from filter → evaluation
const transResult = await executeTransition(
project.id, mainTrack.id, filterStage.id, evalStage.id,
'PENDING', admin.id, prisma
)
expect(transResult.success).toBe(true)
// Step 2: Evaluate routing rules
const matchedRule = await evaluateRoutingRules(
project.id, evalStage.id, pipeline.id, prisma
)
expect(matchedRule).not.toBeNull()
expect(matchedRule!.destinationTrackId).toBe(awardTrack.id)
expect(matchedRule!.routingMode).toBe('PARALLEL')
// Step 3: Execute routing
const routeResult = await executeRouting(project.id, matchedRule!, admin.id, prisma)
expect(routeResult.success).toBe(true)
// Verify: Project should be in BOTH main eval stage AND award eval stage
const mainPSS = await prisma.projectStageState.findFirst({
where: { projectId: project.id, stageId: evalStage.id, exitedAt: null },
})
expect(mainPSS).not.toBeNull() // Still in main track
const awardPSS = await prisma.projectStageState.findFirst({
where: { projectId: project.id, stageId: awardEvalStage.id, exitedAt: null },
})
expect(awardPSS).not.toBeNull() // Also in award track
expect(awardPSS!.state).toBe('PENDING')
})
})

View File

@ -1,128 +0,0 @@
/**
* U-003: Routing Multiple Rule Match (highest priority wins)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestPipeline,
createTestTrack,
createTestStage,
createTestProject,
createTestPSS,
createTestRoutingRule,
cleanupTestData,
} from '../helpers'
import { evaluateRoutingRules } from '@/server/services/routing-engine'
let programId: string
let userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram({ name: 'Routing Test' })
programId = program.id
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
describe('U-003: Multiple Rule Match — Highest Priority Wins', () => {
it('returns the lowest-priority-number rule when multiple rules match', async () => {
const pipeline = await createTestPipeline(programId)
const mainTrack = await createTestTrack(pipeline.id, {
name: 'Main',
kind: 'MAIN',
sortOrder: 0,
})
const awardTrackA = await createTestTrack(pipeline.id, {
name: 'Award A',
kind: 'AWARD',
sortOrder: 1,
routingMode: 'PARALLEL',
})
const awardTrackB = await createTestTrack(pipeline.id, {
name: 'Award B',
kind: 'AWARD',
sortOrder: 2,
routingMode: 'PARALLEL',
})
const awardTrackC = await createTestTrack(pipeline.id, {
name: 'Award C',
kind: 'AWARD',
sortOrder: 3,
routingMode: 'PARALLEL',
})
const stage = await createTestStage(mainTrack.id, {
name: 'Eval',
stageType: 'EVALUATION',
status: 'STAGE_ACTIVE',
})
// Create destination stages
await createTestStage(awardTrackA.id, { name: 'A-Eval', sortOrder: 0 })
await createTestStage(awardTrackB.id, { name: 'B-Eval', sortOrder: 0 })
await createTestStage(awardTrackC.id, { name: 'C-Eval', sortOrder: 0 })
// Project from France — all 3 rules match since country=France
const project = await createTestProject(programId, { country: 'France' })
await createTestPSS(project.id, mainTrack.id, stage.id, { state: 'PENDING' })
// Rule 1: priority 10 (lowest = wins)
await createTestRoutingRule(pipeline.id, awardTrackA.id, {
name: 'Award A Rule',
priority: 10,
predicateJson: { field: 'project.country', operator: 'eq', value: 'France' },
})
// Rule 2: priority 20
await createTestRoutingRule(pipeline.id, awardTrackB.id, {
name: 'Award B Rule',
priority: 20,
predicateJson: { field: 'project.country', operator: 'eq', value: 'France' },
})
// Rule 3: priority 30
await createTestRoutingRule(pipeline.id, awardTrackC.id, {
name: 'Award C Rule',
priority: 30,
predicateJson: { field: 'project.country', operator: 'eq', value: 'France' },
})
const matched = await evaluateRoutingRules(project.id, stage.id, pipeline.id, prisma)
expect(matched).not.toBeNull()
expect(matched!.destinationTrackId).toBe(awardTrackA.id)
expect(matched!.priority).toBe(10)
expect(matched!.ruleName).toBe('Award A Rule')
})
it('returns null when no rules match', async () => {
const pipeline = await createTestPipeline(programId)
const mainTrack = await createTestTrack(pipeline.id, { kind: 'MAIN', sortOrder: 0 })
const awardTrack = await createTestTrack(pipeline.id, { kind: 'AWARD', sortOrder: 1 })
const stage = await createTestStage(mainTrack.id, {
name: 'Eval',
status: 'STAGE_ACTIVE',
})
await createTestStage(awardTrack.id, { name: 'A-Eval', sortOrder: 0 })
// Project from Germany, but rule matches only France
const project = await createTestProject(programId, { country: 'Germany' })
await createTestPSS(project.id, mainTrack.id, stage.id, { state: 'PENDING' })
await createTestRoutingRule(pipeline.id, awardTrack.id, {
name: 'France Only Rule',
priority: 0,
predicateJson: { field: 'project.country', operator: 'eq', value: 'France' },
})
const matched = await evaluateRoutingRules(project.id, stage.id, pipeline.id, prisma)
expect(matched).toBeNull()
})
})