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`,
|
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 8. RoutingRule destinations reference valid tracks in same pipeline
|
// 8. LiveProgressCursor references valid stage
|
||||||
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
|
|
||||||
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")
|
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 {
|
enum RoutingMode {
|
||||||
PARALLEL
|
SHARED
|
||||||
EXCLUSIVE
|
EXCLUSIVE
|
||||||
POST_MAIN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StageStatus {
|
enum StageStatus {
|
||||||
|
|
@ -1846,7 +1845,6 @@ model Pipeline {
|
||||||
// Relations
|
// Relations
|
||||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
tracks Track[]
|
tracks Track[]
|
||||||
routingRules RoutingRule[]
|
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -1871,8 +1869,6 @@ model Track {
|
||||||
stages Stage[]
|
stages Stage[]
|
||||||
projectStageStates ProjectStageState[]
|
projectStageStates ProjectStageState[]
|
||||||
specialAward SpecialAward?
|
specialAward SpecialAward?
|
||||||
routingRulesAsSource RoutingRule[] @relation("RoutingSourceTrack")
|
|
||||||
routingRulesAsDestination RoutingRule[] @relation("RoutingDestinationTrack")
|
|
||||||
|
|
||||||
@@unique([pipelineId, slug])
|
@@unique([pipelineId, slug])
|
||||||
@@unique([pipelineId, sortOrder])
|
@@unique([pipelineId, sortOrder])
|
||||||
|
|
@ -1969,30 +1965,6 @@ model ProjectStageState {
|
||||||
@@index([projectId, trackId])
|
@@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 {
|
model Cohort {
|
||||||
id String @id @default(cuid())
|
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,
|
'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 {
|
function mapCategory(raw: string | undefined): CompetitionCategory | null {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const trimmed = raw.trim()
|
const trimmed = normalizeSpaces(raw.trim())
|
||||||
for (const [prefix, value] of Object.entries(categoryMap)) {
|
for (const [prefix, value] of Object.entries(categoryMap)) {
|
||||||
if (trimmed.startsWith(prefix)) return value
|
if (trimmed.startsWith(prefix)) return value
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +66,7 @@ function mapCategory(raw: string | undefined): CompetitionCategory | null {
|
||||||
|
|
||||||
function mapIssue(raw: string | undefined): OceanIssue | null {
|
function mapIssue(raw: string | undefined): OceanIssue | null {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const trimmed = raw.trim()
|
const trimmed = normalizeSpaces(raw.trim())
|
||||||
for (const [prefix, value] of Object.entries(issueMap)) {
|
for (const [prefix, value] of Object.entries(issueMap)) {
|
||||||
if (trimmed.startsWith(prefix)) return value
|
if (trimmed.startsWith(prefix)) return value
|
||||||
}
|
}
|
||||||
|
|
@ -76,17 +81,11 @@ function parseFoundedDate(raw: string | undefined): Date | null {
|
||||||
return isNaN(d.getTime()) ? null : d
|
return isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidEntry(row: Record<string, string>): boolean {
|
function isEmptyRow(row: Record<string, string>): boolean {
|
||||||
const status = (row['Application status'] || '').trim().toLowerCase()
|
|
||||||
if (status === 'ignore' || status === 'doublon') return false
|
|
||||||
|
|
||||||
const name = (row['Full name'] || '').trim()
|
const name = (row['Full name'] || '').trim()
|
||||||
if (name.length <= 2) return false // skip test entries
|
|
||||||
|
|
||||||
const email = (row['E-mail'] || '').trim()
|
const email = (row['E-mail'] || '').trim()
|
||||||
if (!email || !email.includes('@')) return false
|
const project = (row["Project's name"] || '').trim()
|
||||||
|
return !name && !email && !project
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -476,7 +475,7 @@ async function main() {
|
||||||
name: 'Ocean Innovation Award',
|
name: 'Ocean Innovation Award',
|
||||||
slug: 'innovation-award',
|
slug: 'innovation-award',
|
||||||
kind: TrackKind.AWARD,
|
kind: TrackKind.AWARD,
|
||||||
routingMode: RoutingMode.PARALLEL,
|
routingMode: RoutingMode.SHARED,
|
||||||
decisionMode: DecisionMode.JURY_VOTE,
|
decisionMode: DecisionMode.JURY_VOTE,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
settingsJson: { description: 'Award for most innovative ocean technology' },
|
settingsJson: { description: 'Award for most innovative ocean technology' },
|
||||||
|
|
@ -506,16 +505,16 @@ async function main() {
|
||||||
name: "People's Choice",
|
name: "People's Choice",
|
||||||
slug: 'peoples-choice',
|
slug: 'peoples-choice',
|
||||||
kind: TrackKind.SHOWCASE,
|
kind: TrackKind.SHOWCASE,
|
||||||
routingMode: RoutingMode.POST_MAIN,
|
routingMode: RoutingMode.SHARED,
|
||||||
sortOrder: 3,
|
sortOrder: 3,
|
||||||
settingsJson: { description: 'Public audience voting for fan favorite' },
|
settingsJson: { description: 'Public audience voting for fan favorite' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(` ✓ Main Competition (MAIN)`)
|
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(` ✓ Ocean Impact Award (AWARD, EXCLUSIVE)`)
|
||||||
console.log(` ✓ People's Choice (SHOWCASE, POST_MAIN)`)
|
console.log(` ✓ People's Choice (SHOWCASE, SHARED)`)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 9. Stages
|
// 9. Stages
|
||||||
|
|
@ -814,21 +813,9 @@ async function main() {
|
||||||
|
|
||||||
console.log(` Raw CSV rows: ${records.length}`)
|
console.log(` Raw CSV rows: ${records.length}`)
|
||||||
|
|
||||||
// Filter and deduplicate
|
// Skip only completely empty rows (no name, no email, no project)
|
||||||
const seenEmails = new Set<string>()
|
const validRecords = records.filter((row: Record<string, string>) => !isEmptyRow(row))
|
||||||
const validRecords: Record<string, string>[] = []
|
console.log(` Entries to seed: ${validRecords.length}`)
|
||||||
|
|
||||||
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}`)
|
|
||||||
|
|
||||||
// Create applicant users and projects
|
// Create applicant users and projects
|
||||||
console.log('\n🚀 Creating 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 intakeStage = mainStages[0] // INTAKE - CLOSED
|
||||||
const filterStage = mainStages[1] // FILTER - ACTIVE
|
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 email = (row['E-mail'] || '').trim().toLowerCase()
|
||||||
const name = (row['Full name'] || '').trim()
|
const name = (row['Full name'] || '').trim()
|
||||||
const phone = (row['Téléphone'] || '').trim() || null
|
const phone = (row['Téléphone'] || '').trim() || null
|
||||||
|
|
@ -855,7 +844,14 @@ async function main() {
|
||||||
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
|
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
|
||||||
const foundedAt = parseFoundedDate(row['Date of creation'])
|
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({
|
const user = await prisma.user.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
update: {
|
update: {
|
||||||
|
|
@ -864,7 +860,7 @@ async function main() {
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email,
|
email,
|
||||||
name,
|
name: name || `Applicant ${rowIdx + 1}`,
|
||||||
role: UserRole.APPLICANT,
|
role: UserRole.APPLICANT,
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
|
|
@ -930,6 +926,9 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ✓ Created ${projectCount} projects with stage states`)
|
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')
|
console.log(' ✓ Ocean Impact Award → impact-award track')
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 14. Routing Rules
|
// 14. Notification Email Settings
|
||||||
// ==========================================================================
|
|
||||||
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
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
console.log('\n🔔 Creating 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 { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-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 { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||||
|
|
@ -601,16 +600,6 @@ export default function PipelineDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routing Rules (only if multiple tracks) */}
|
|
||||||
{hasMultipleTracks && (
|
|
||||||
<div>
|
|
||||||
<RoutingRulesEditor
|
|
||||||
pipelineId={pipelineId}
|
|
||||||
tracks={trackOptionsForEditors}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Award Governance (only if award tracks exist) */}
|
{/* Award Governance (only if award tracks exist) */}
|
||||||
{hasAwardTracks && (
|
{hasAwardTracks && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export default function EditPipelineWizardPage() {
|
||||||
slug: track.slug,
|
slug: track.slug,
|
||||||
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
||||||
sortOrder: track.sortOrder,
|
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,
|
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
||||||
stages: track.stages.map(s => ({
|
stages: track.stages.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { Plus, X, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
// ─── Field & Operator Definitions ────────────────────────────────────────────
|
// ─── Field & Operator Definitions ────────────────────────────────────────────
|
||||||
|
|
@ -289,22 +288,8 @@ function AIMode({
|
||||||
explanation: string
|
explanation: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const parseRule = trpc.routing.parseNaturalLanguageRule.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setResult(data)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
if (!text.trim()) return
|
toast.error('AI rule parsing is not currently available')
|
||||||
if (!pipelineId) {
|
|
||||||
toast.error('Pipeline ID is required for AI parsing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parseRule.mutate({ text: text.trim(), pipelineId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
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"'
|
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
disabled={parseRule.isPending}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={!text.trim() || parseRule.isPending || !pipelineId}
|
disabled={!text.trim() || !pipelineId}
|
||||||
>
|
>
|
||||||
{parseRule.isPending ? (
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<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
|
Generate Rule
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Label className="text-xs">Routing Mode</Label>
|
<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>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={track.routingModeDefault ?? 'PARALLEL'}
|
value={track.routingModeDefault ?? 'SHARED'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateAward(index, {
|
updateAward(index, {
|
||||||
routingModeDefault: value as RoutingMode,
|
routingModeDefault: value as RoutingMode,
|
||||||
|
|
@ -164,15 +164,12 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="PARALLEL">
|
<SelectItem value="SHARED">
|
||||||
Parallel — Runs alongside main track
|
Shared — Projects compete in main + this award
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="EXCLUSIVE">
|
<SelectItem value="EXCLUSIVE">
|
||||||
Exclusive — Projects enter only this track
|
Exclusive — Projects enter only this track
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="POST_MAIN">
|
|
||||||
Post-Main — After main track completes
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,6 @@ const NOTIFICATION_EVENTS = [
|
||||||
label: 'Assignments Generated',
|
label: 'Assignments Generated',
|
||||||
description: 'When jury assignments are created or updated',
|
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',
|
key: 'live.cursor.updated',
|
||||||
label: 'Live Cursor Updated',
|
label: 'Live Cursor Updated',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ type TrackInput = {
|
||||||
slug: string
|
slug: string
|
||||||
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null
|
routingMode: 'SHARED' | 'EXCLUSIVE' | null
|
||||||
decisionMode:
|
decisionMode:
|
||||||
| 'JURY_VOTE'
|
| 'JURY_VOTE'
|
||||||
| 'AWARD_MASTER_DECISION'
|
| 'AWARD_MASTER_DECISION'
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function defaultAwardTrack(index: number): WizardTrackConfig {
|
||||||
slug: slugify(name),
|
slug: slugify(name),
|
||||||
kind: 'AWARD',
|
kind: 'AWARD',
|
||||||
sortOrder: index + 1,
|
sortOrder: index + 1,
|
||||||
routingModeDefault: 'PARALLEL',
|
routingModeDefault: 'SHARED',
|
||||||
decisionMode: 'JURY_VOTE',
|
decisionMode: 'JURY_VOTE',
|
||||||
stages: [
|
stages: [
|
||||||
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 0, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
|
{ 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,
|
'stage.transitioned': true,
|
||||||
'filtering.completed': true,
|
'filtering.completed': true,
|
||||||
'assignment.generated': true,
|
'assignment.generated': true,
|
||||||
'routing.executed': true,
|
|
||||||
'live.cursor.updated': true,
|
'live.cursor.updated': true,
|
||||||
'cohort.window.changed': true,
|
'cohort.window.changed': true,
|
||||||
'decision.overridden': true,
|
'decision.overridden': true,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import { dashboardRouter } from './dashboard'
|
||||||
// Round redesign Phase 2 routers
|
// Round redesign Phase 2 routers
|
||||||
import { pipelineRouter } from './pipeline'
|
import { pipelineRouter } from './pipeline'
|
||||||
import { stageRouter } from './stage'
|
import { stageRouter } from './stage'
|
||||||
import { routingRouter } from './routing'
|
|
||||||
import { stageFilteringRouter } from './stageFiltering'
|
import { stageFilteringRouter } from './stageFiltering'
|
||||||
import { stageAssignmentRouter } from './stageAssignment'
|
import { stageAssignmentRouter } from './stageAssignment'
|
||||||
import { cohortRouter } from './cohort'
|
import { cohortRouter } from './cohort'
|
||||||
|
|
@ -87,8 +87,7 @@ export const appRouter = router({
|
||||||
// Round redesign Phase 2 routers
|
// Round redesign Phase 2 routers
|
||||||
pipeline: pipelineRouter,
|
pipeline: pipelineRouter,
|
||||||
stage: stageRouter,
|
stage: stageRouter,
|
||||||
routing: routingRouter,
|
stageFiltering: stageFilteringRouter,
|
||||||
stageFiltering: stageFilteringRouter,
|
|
||||||
stageAssignment: stageAssignmentRouter,
|
stageAssignment: stageAssignmentRouter,
|
||||||
cohort: cohortRouter,
|
cohort: cohortRouter,
|
||||||
live: liveRouter,
|
live: liveRouter,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const awardRouter = router({
|
||||||
pipelineId: z.string(),
|
pipelineId: z.string(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
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(),
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||||
settingsJson: z.record(z.unknown()).optional(),
|
settingsJson: z.record(z.unknown()).optional(),
|
||||||
awardConfig: z.object({
|
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: { 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 },
|
where: { programId: input.programId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
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-]+$/),
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
||||||
sortOrder: z.number().int().min(0),
|
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(),
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||||
stages: z.array(
|
stages: z.array(
|
||||||
z.object({
|
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-]+$/),
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||||
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
||||||
sortOrder: z.number().int().min(0),
|
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(),
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||||
stages: z.array(
|
stages: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -866,10 +855,6 @@ export const pipelineRouter = router({
|
||||||
tracks: {
|
tracks: {
|
||||||
include: { stages: { orderBy: { sortOrder: 'asc' } } },
|
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
|
// Simulate: for each project, determine which track/stage it would land in
|
||||||
const mainTrack = pipeline.tracks.find((t) => t.kind === 'MAIN')
|
const mainTrack = pipeline.tracks.find((t) => t.kind === 'MAIN')
|
||||||
const simulations = projects.map((project) => {
|
const simulations = projects.map((project) => {
|
||||||
// Default: route to first stage of MAIN track
|
const targetTrack = mainTrack
|
||||||
let targetTrack = mainTrack
|
const targetStage = mainTrack?.stages[0] ?? null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: project.id,
|
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')
|
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 passed = 0
|
||||||
let rejected = 0
|
let rejected = 0
|
||||||
let manualQueue = 0
|
let manualQueue = 0
|
||||||
|
|
@ -271,6 +299,20 @@ export async function runStageFiltering(
|
||||||
let deterministicPassed = true
|
let deterministicPassed = true
|
||||||
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
|
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
|
// 1. Run deterministic rules
|
||||||
for (const rule of deterministicRules) {
|
for (const rule of deterministicRules) {
|
||||||
const config = rule.configJson as unknown as RuleConfig
|
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 aiScreeningJson: Record<string, unknown> | null = null
|
||||||
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
|
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
|
// Build a simplified AI screening result using the existing AI criteria
|
||||||
// In production this would call OpenAI via the ai-filtering service
|
// In production this would call OpenAI via the ai-filtering service
|
||||||
const aiRule = aiRules[0]
|
const aiRule = aiRules[0]
|
||||||
|
|
@ -337,12 +380,25 @@ export async function runStageFiltering(
|
||||||
: 'Insufficient project data for AI screening',
|
: '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({
|
const banded = bandByConfidence({
|
||||||
confidence,
|
confidence,
|
||||||
meetsAllCriteria: hasMinimalData,
|
meetsAllCriteria: hasMinimalData,
|
||||||
})
|
})
|
||||||
|
|
||||||
finalOutcome = banded.outcome
|
// For non-duplicate projects, use AI banding; for duplicates, keep FLAGGED
|
||||||
|
if (!isDuplicate) {
|
||||||
|
finalOutcome = banded.outcome
|
||||||
|
}
|
||||||
|
|
||||||
ruleResults.push({
|
ruleResults.push({
|
||||||
ruleId: aiRule.id,
|
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({
|
await prisma.filteringResult.upsert({
|
||||||
where: {
|
where: {
|
||||||
stageId_projectId: {
|
stageId_projectId: {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*
|
*
|
||||||
* Event types follow a dotted convention:
|
* Event types follow a dotted convention:
|
||||||
* stage.transitioned, filtering.completed, assignment.generated,
|
* 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'
|
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
|
@ -32,7 +32,6 @@ const EVENT_TYPES = {
|
||||||
STAGE_TRANSITIONED: 'stage.transitioned',
|
STAGE_TRANSITIONED: 'stage.transitioned',
|
||||||
FILTERING_COMPLETED: 'filtering.completed',
|
FILTERING_COMPLETED: 'filtering.completed',
|
||||||
ASSIGNMENT_GENERATED: 'assignment.generated',
|
ASSIGNMENT_GENERATED: 'assignment.generated',
|
||||||
ROUTING_EXECUTED: 'routing.executed',
|
|
||||||
CURSOR_UPDATED: 'live.cursor_updated',
|
CURSOR_UPDATED: 'live.cursor_updated',
|
||||||
DECISION_OVERRIDDEN: 'decision.overridden',
|
DECISION_OVERRIDDEN: 'decision.overridden',
|
||||||
} as const
|
} as const
|
||||||
|
|
@ -41,7 +40,6 @@ const EVENT_TITLES: Record<string, string> = {
|
||||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
|
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
|
||||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
|
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
|
||||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
|
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
|
||||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'Routing Executed',
|
|
||||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
|
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
|
||||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
|
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +48,6 @@ const EVENT_ICONS: Record<string, string> = {
|
||||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
|
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
|
||||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
|
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
|
||||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
|
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
|
||||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'GitBranch',
|
|
||||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
|
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
|
||||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
|
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +56,6 @@ const EVENT_PRIORITIES: Record<string, string> = {
|
||||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
|
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
|
||||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
|
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
|
||||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
|
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
|
||||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'normal',
|
|
||||||
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
|
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
|
||||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
|
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +216,6 @@ async function resolveRecipients(
|
||||||
case EVENT_TYPES.STAGE_TRANSITIONED:
|
case EVENT_TYPES.STAGE_TRANSITIONED:
|
||||||
case EVENT_TYPES.FILTERING_COMPLETED:
|
case EVENT_TYPES.FILTERING_COMPLETED:
|
||||||
case EVENT_TYPES.ASSIGNMENT_GENERATED:
|
case EVENT_TYPES.ASSIGNMENT_GENERATED:
|
||||||
case EVENT_TYPES.ROUTING_EXECUTED:
|
|
||||||
case EVENT_TYPES.DECISION_OVERRIDDEN: {
|
case EVENT_TYPES.DECISION_OVERRIDDEN: {
|
||||||
// Notify admins
|
// Notify admins
|
||||||
const admins = await prisma.user.findMany({
|
const admins = await prisma.user.findMany({
|
||||||
|
|
@ -311,12 +306,6 @@ function buildNotificationMessage(
|
||||||
return `${count ?? 0} assignments were generated for the stage.`
|
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: {
|
case EVENT_TYPES.CURSOR_UPDATED: {
|
||||||
const projectId = details.projectId as string | undefined
|
const projectId = details.projectId as string | undefined
|
||||||
const action = details.action 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.
|
* Emit a live.cursor_updated event when the live cursor position changes.
|
||||||
* Called from live-control.ts after setActiveProject or jumpToProject.
|
* 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 ────────────────────────────────────────────────
|
// ─── 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.evaluationDiscussion.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||||
await prisma.projectStageState.deleteMany({ where: { track: { pipeline: { programId } } } })
|
await prisma.projectStageState.deleteMany({ where: { track: { pipeline: { programId } } } })
|
||||||
await prisma.stageTransition.deleteMany({ where: { fromStage: { 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.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
await prisma.awardJuror.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