From 9ab4717f96e9d39e17abaac482afae5d24cff009 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 15 Feb 2026 14:25:05 +0100 Subject: [PATCH] Simplify routing to award assignment, seed all CSV entries, fix category mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- prisma/integrity-checks.ts | 21 +- .../migration.sql | 28 + prisma/schema.prisma | 30 +- prisma/seed.ts | 118 +--- .../admin/rounds/pipeline/[id]/page.tsx | 11 - .../rounds/pipeline/[id]/wizard/page.tsx | 2 +- .../admin/pipeline/predicate-builder.tsx | 28 +- .../admin/pipeline/routing-rules-editor.tsx | 633 ------------------ .../pipeline/sections/awards-section.tsx | 11 +- .../sections/notifications-section.tsx | 5 - src/lib/pipeline-conversions.ts | 2 +- src/lib/pipeline-defaults.ts | 3 +- src/server/routers/_app.ts | 5 +- src/server/routers/award.ts | 2 +- src/server/routers/pipeline.ts | 292 +++----- src/server/routers/routing.ts | 519 -------------- src/server/services/routing-engine.ts | 505 -------------- src/server/services/stage-filtering.ts | 68 +- src/server/services/stage-notifications.ts | 41 +- tests/helpers.ts | 30 - tests/integration/award-exclusive.test.ts | 99 --- tests/integration/transition-routing.test.ts | 117 ---- tests/unit/routing-engine.test.ts | 128 ---- 23 files changed, 249 insertions(+), 2449 deletions(-) create mode 100644 prisma/migrations/20260214000000_simplify_routing_to_award_assignment/migration.sql delete mode 100644 src/components/admin/pipeline/routing-rules-editor.tsx delete mode 100644 src/server/routers/routing.ts delete mode 100644 src/server/services/routing-engine.ts delete mode 100644 tests/integration/award-exclusive.test.ts delete mode 100644 tests/integration/transition-routing.test.ts delete mode 100644 tests/unit/routing-engine.test.ts diff --git a/prisma/integrity-checks.ts b/prisma/integrity-checks.ts index b2f10b0..60ee7ef 100644 --- a/prisma/integrity-checks.ts +++ b/prisma/integrity-checks.ts @@ -124,26 +124,7 @@ async function runChecks(): Promise { : `${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") diff --git a/prisma/migrations/20260214000000_simplify_routing_to_award_assignment/migration.sql b/prisma/migrations/20260214000000_simplify_routing_to_award_assignment/migration.sql new file mode 100644 index 0000000..746da65 --- /dev/null +++ b/prisma/migrations/20260214000000_simplify_routing_to_award_assignment/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a34e1ac..b527947 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/prisma/seed.ts b/prisma/seed.ts index 99b81bd..9563e5d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -50,9 +50,14 @@ const issueMap: Record = { '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): boolean { - const status = (row['Application status'] || '').trim().toLowerCase() - if (status === 'ignore' || status === 'doublon') return false - +function isEmptyRow(row: Record): 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() - const validRecords: Record[] = [] - - 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) => !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...') diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx index e393278..51c082b 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx @@ -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() { - {/* Routing Rules (only if multiple tracks) */} - {hasMultipleTracks && ( -
- -
- )} - {/* Award Governance (only if award tracks exist) */} {hasAwardTracks && (
diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx index fdfcae4..d568777 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/wizard/page.tsx @@ -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, diff --git a/src/components/admin/pipeline/predicate-builder.tsx b/src/components/admin/pipeline/predicate-builder.tsx index 2db9b92..3b5f492 100644 --- a/src/components/admin/pipeline/predicate-builder.tsx +++ b/src/components/admin/pipeline/predicate-builder.tsx @@ -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} />
diff --git a/src/components/admin/pipeline/routing-rules-editor.tsx b/src/components/admin/pipeline/routing-rules-editor.tsx deleted file mode 100644 index a106854..0000000 --- a/src/components/admin/pipeline/routing-rules-editor.tsx +++ /dev/null @@ -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 -} - -const DEFAULT_PREDICATE = { - field: 'competitionCategory', - operator: 'eq', - value: 'STARTUP', -} - -// ─── Predicate Summarizer ──────────────────────────────────────────────────── - -const FIELD_LABELS: Record = { - competitionCategory: 'Competition Category', - oceanIssue: 'Ocean Issue', - country: 'Country', - geographicZone: 'Geographic Zone', - wantsMentorship: 'Wants Mentorship', - tags: 'Tags', -} - -const OPERATOR_LABELS: Record = { - 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 { - // 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> - 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) => 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 ( - onToggleExpand(draft.id)}> - {/* Collapsed header */} - - - - - {/* Expanded content */} - -
- {/* Rule name */} -
- - onUpdateDraft(draft.id, { name: e.target.value })} - /> -
- - {/* Track routing flow: Source → Destination Track → Destination Stage */} -
- -
-
- -
- - - -
- -
- - - -
- -
-
-
- - {/* Predicate builder */} -
- - - onUpdateDraft(draft.id, { predicateJson: predicate }) - } - pipelineId={pipelineId} - /> -
- - {/* Advanced settings (collapsible) */} - - - - - -
- -
-
- - - - - - - Global: applies to all projects. Track/Stage: only applies to projects in a specific track or stage. - - -
- -
-
- -
- - - onUpdateDraft(draft.id, { - priority: parseInt(e.target.value, 10) || 0, - }) - } - /> -
-
-
-
- - {/* Action bar */} -
- - - -
-
-
-
- ) -} - -// ─── Main Component ────────────────────────────────────────────────────────── - -export function RoutingRulesEditor({ - pipelineId, - tracks, -}: RoutingRulesEditorProps) { - const utils = trpc.useUtils() - const [drafts, setDrafts] = useState>({}) - const [expandedId, setExpandedId] = useState(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 = {} - 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) ?? {}, - } - } - 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) => { - setDrafts((prev) => ({ - ...prev, - [id]: { ...prev[id], ...updates }, - })) - } - - const handleToggleExpand = (id: string) => { - setExpandedId((prev) => (prev === id ? null : id)) - } - - if (isLoading) { - return ( -
-
- -

Routing Rules

-
-
- - Loading routing rules... -
-
- ) - } - - return ( -
- {/* Section header */} - -
-
- -

Routing Rules

- - - - - - Routing rules determine which track a project enters based on its attributes. Rules are evaluated in priority order -- the first matching rule wins. - - -
- -
-
- - {/* Rule list */} - {orderedRules.length === 0 ? ( -
- -

No routing rules yet

-

- Add a rule to automatically route projects into tracks based on their attributes. -

-
- ) : ( -
- {orderedRules.map((rule, index) => { - const draft = drafts[rule.id] - if (!draft) return null - - return ( - deleteRule.mutate({ id })} - onToggleActive={(id, isActive) => toggleRule.mutate({ id, isActive })} - isSaving={upsertRule.isPending} - isDeleting={deleteRule.isPending} - isToggling={toggleRule.isPending} - /> - ) - })} -
- )} -
- ) -} diff --git a/src/components/admin/pipeline/sections/awards-section.tsx b/src/components/admin/pipeline/sections/awards-section.tsx index 4cb2b60..45b9ef6 100644 --- a/src/components/admin/pipeline/sections/awards-section.tsx +++ b/src/components/admin/pipeline/sections/awards-section.tsx @@ -149,10 +149,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
- +
diff --git a/src/components/admin/pipeline/sections/notifications-section.tsx b/src/components/admin/pipeline/sections/notifications-section.tsx index baf2e10..b9f4952 100644 --- a/src/components/admin/pipeline/sections/notifications-section.tsx +++ b/src/components/admin/pipeline/sections/notifications-section.tsx @@ -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', diff --git a/src/lib/pipeline-conversions.ts b/src/lib/pipeline-conversions.ts index 2ffd0e1..eb3f3d9 100644 --- a/src/lib/pipeline-conversions.ts +++ b/src/lib/pipeline-conversions.ts @@ -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' diff --git a/src/lib/pipeline-defaults.ts b/src/lib/pipeline-defaults.ts index 868eadb..a7b2a9c 100644 --- a/src/lib/pipeline-defaults.ts +++ b/src/lib/pipeline-defaults.ts @@ -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 }, @@ -125,7 +125,6 @@ export function defaultNotificationConfig(): Record { 'stage.transitioned': true, 'filtering.completed': true, 'assignment.generated': true, - 'routing.executed': true, 'live.cursor.updated': true, 'cohort.window.changed': true, 'decision.overridden': true, diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 83f88b4..63fd20b 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -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,8 +87,7 @@ export const appRouter = router({ // Round redesign Phase 2 routers pipeline: pipelineRouter, stage: stageRouter, - routing: routingRouter, - stageFiltering: stageFilteringRouter, +stageFiltering: stageFilteringRouter, stageAssignment: stageAssignmentRouter, cohort: cohortRouter, live: liveRouter, diff --git a/src/server/routers/award.ts b/src/server/routers/award.ts index ec063f5..6cfffb3 100644 --- a/src/server/routers/award.ts +++ b/src/server/routers/award.ts @@ -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({ diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index 792d058..8f4aa7d 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -1,9 +1,9 @@ -import { z } from 'zod' -import { TRPCError } from '@trpc/server' -import { Prisma } from '@prisma/client' -import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc' -import { logAudit } from '@/server/utils/audit' -import { parseAndValidateStageConfig } from '@/lib/stage-config-schema' +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { Prisma } from '@prisma/client' +import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc' +import { logAudit } from '@/server/utils/audit' +import { parseAndValidateStageConfig } from '@/lib/stage-config-schema' export const pipelineRouter = router({ /** @@ -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({ @@ -399,40 +395,40 @@ export const pipelineRouter = router({ }, }) - // 3. Create stages for this track - const createdStages: Array<{ id: string; name: string; sortOrder: number }> = [] - for (const stageInput of trackInput.stages) { - let parsedConfig: Prisma.InputJsonValue | undefined - if (stageInput.configJson !== undefined) { - try { - const { config } = parseAndValidateStageConfig( - stageInput.stageType, - stageInput.configJson, - { strictUnknownKeys: true } - ) - parsedConfig = config as Prisma.InputJsonValue - } catch (error) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - error instanceof Error - ? error.message - : `Invalid config for stage ${stageInput.name}`, - }) - } - } - - const stage = await tx.stage.create({ - data: { - trackId: track.id, - name: stageInput.name, - slug: stageInput.slug, - stageType: stageInput.stageType, - sortOrder: stageInput.sortOrder, - configJson: parsedConfig, - }, - }) - createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder }) + // 3. Create stages for this track + const createdStages: Array<{ id: string; name: string; sortOrder: number }> = [] + for (const stageInput of trackInput.stages) { + let parsedConfig: Prisma.InputJsonValue | undefined + if (stageInput.configJson !== undefined) { + try { + const { config } = parseAndValidateStageConfig( + stageInput.stageType, + stageInput.configJson, + { strictUnknownKeys: true } + ) + parsedConfig = config as Prisma.InputJsonValue + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + error instanceof Error + ? error.message + : `Invalid config for stage ${stageInput.name}`, + }) + } + } + + const stage = await tx.stage.create({ + data: { + trackId: track.id, + name: stageInput.name, + slug: stageInput.slug, + stageType: stageInput.stageType, + sortOrder: stageInput.sortOrder, + configJson: parsedConfig, + }, + }) + createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder }) } // Create SpecialAward if AWARD kind @@ -524,32 +520,25 @@ export const pipelineRouter = router({ }, }, }, - specialAward: { - select: { - id: true, - name: true, - description: true, - criteriaText: true, - useAiEligibility: true, - scoringMode: true, - maxRankedPicks: true, - votingStartAt: true, - votingEndAt: true, - status: true, - }, - }, + specialAward: { + select: { + id: true, + name: true, + description: true, + criteriaText: true, + useAiEligibility: true, + scoringMode: true, + maxRankedPicks: true, + votingStartAt: true, + votingEndAt: true, + status: true, + }, + }, _count: { select: { projectStageStates: true }, }, }, }, - 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({ @@ -738,52 +727,52 @@ export const pipelineRouter = router({ } } - // Create or update stages - for (const stageInput of trackInput.stages) { - let parsedConfig: Prisma.InputJsonValue | undefined - if (stageInput.configJson !== undefined) { - try { - const { config } = parseAndValidateStageConfig( - stageInput.stageType, - stageInput.configJson, - { strictUnknownKeys: true } - ) - parsedConfig = config as Prisma.InputJsonValue - } catch (error) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - error instanceof Error - ? error.message - : `Invalid config for stage ${stageInput.name}`, - }) - } - } - - if (stageInput.id) { - await tx.stage.update({ - where: { id: stageInput.id }, - data: { - name: stageInput.name, - slug: stageInput.slug, - stageType: stageInput.stageType, - sortOrder: stageInput.sortOrder, - configJson: parsedConfig, - }, - }) - allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId }) - } else { - const newStage = await tx.stage.create({ - data: { - trackId, - name: stageInput.name, - slug: stageInput.slug, - stageType: stageInput.stageType, - sortOrder: stageInput.sortOrder, - configJson: parsedConfig, - }, - }) - allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId }) + // Create or update stages + for (const stageInput of trackInput.stages) { + let parsedConfig: Prisma.InputJsonValue | undefined + if (stageInput.configJson !== undefined) { + try { + const { config } = parseAndValidateStageConfig( + stageInput.stageType, + stageInput.configJson, + { strictUnknownKeys: true } + ) + parsedConfig = config as Prisma.InputJsonValue + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + error instanceof Error + ? error.message + : `Invalid config for stage ${stageInput.name}`, + }) + } + } + + if (stageInput.id) { + await tx.stage.update({ + where: { id: stageInput.id }, + data: { + name: stageInput.name, + slug: stageInput.slug, + stageType: stageInput.stageType, + sortOrder: stageInput.sortOrder, + configJson: parsedConfig, + }, + }) + allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId }) + } else { + const newStage = await tx.stage.create({ + data: { + trackId, + name: stageInput.name, + slug: stageInput.slug, + stageType: stageInput.stageType, + sortOrder: stageInput.sortOrder, + configJson: parsedConfig, + }, + }) + allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId }) } } } @@ -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 - 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, - 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) ?? {} - 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 - } -} diff --git a/src/server/routers/routing.ts b/src/server/routers/routing.ts deleted file mode 100644 index 7b50ba1..0000000 --- a/src/server/routers/routing.ts +++ /dev/null @@ -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": "", "operator": "", "value": "" } -- Compound (AND): { "logic": "and", "conditions": [, ...] } -- Compound (OR): { "logic": "or", "conditions": [, ...] } - -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; explanation: string } - try { - parsed = JSON.parse(content) as { predicate: Record; 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', - } - }), -}) diff --git a/src/server/services/routing-engine.ts b/src/server/services/routing-engine.ts deleted file mode 100644 index cee01ca..0000000 --- a/src/server/services/routing-engine.ts +++ /dev/null @@ -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 -): 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, - 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)[part] - } - - return current -} - -function evaluatePredicate( - node: PredicateNode, - context: Record -): 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> { - 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 { - 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 { - 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 { - 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 -} diff --git a/src/server/services/stage-filtering.ts b/src/server/services/stage-filtering.ts index 4b30494..3e41dcb 100644 --- a/src/server/services/stage-filtering.ts +++ b/src/server/services/stage-filtering.ts @@ -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() + const emailToProjects = new Map>() + + 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 = 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 | 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, }) - finalOutcome = banded.outcome + // 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: { diff --git a/src/server/services/stage-notifications.ts b/src/server/services/stage-notifications.ts index c43c667..7e8431f 100644 --- a/src/server/services/stage-notifications.ts +++ b/src/server/services/stage-notifications.ts @@ -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 = { [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 = { [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 = { [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 { - 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. diff --git a/tests/helpers.ts b/tests/helpers.ts index b9a1b2e..a831b02 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 - 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 } } } }) diff --git a/tests/integration/award-exclusive.test.ts b/tests/integration/award-exclusive.test.ts deleted file mode 100644 index 24fad61..0000000 --- a/tests/integration/award-exclusive.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/tests/integration/transition-routing.test.ts b/tests/integration/transition-routing.test.ts deleted file mode 100644 index 724b9a6..0000000 --- a/tests/integration/transition-routing.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/tests/unit/routing-engine.test.ts b/tests/unit/routing-engine.test.ts deleted file mode 100644 index 50bfee7..0000000 --- a/tests/unit/routing-engine.test.ts +++ /dev/null @@ -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() - }) -})