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:
parent
382570cebd
commit
9ab4717f96
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
118
prisma/seed.ts
118
prisma/seed.ts
|
|
@ -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...')
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"> → </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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } } } })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue