Simplify routing to award assignment, seed all CSV entries, fix category mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s
- Remove RoutingRule model and routing engine (replaced by direct award assignment) - Simplify RoutingMode enum: PARALLEL/POST_MAIN → SHARED, keep EXCLUSIVE - Remove routing router, routing-rules-editor, and related tests - Update pipeline, award, and notification code to remove routing references - Seed: include all CSV entries (no filtering/dedup), AI screening handles duplicates - Seed: fix non-breaking space (U+00A0) bug in category/issue mapping - Stage filtering: add duplicate detection that flags projects for admin review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ import { dashboardRouter } from './dashboard'
|
||||
// Round redesign Phase 2 routers
|
||||
import { pipelineRouter } from './pipeline'
|
||||
import { stageRouter } from './stage'
|
||||
import { routingRouter } from './routing'
|
||||
|
||||
import { stageFilteringRouter } from './stageFiltering'
|
||||
import { stageAssignmentRouter } from './stageAssignment'
|
||||
import { cohortRouter } from './cohort'
|
||||
@@ -87,8 +87,7 @@ export const appRouter = router({
|
||||
// Round redesign Phase 2 routers
|
||||
pipeline: pipelineRouter,
|
||||
stage: stageRouter,
|
||||
routing: routingRouter,
|
||||
stageFiltering: stageFilteringRouter,
|
||||
stageFiltering: stageFilteringRouter,
|
||||
stageAssignment: stageAssignmentRouter,
|
||||
cohort: cohortRouter,
|
||||
live: liveRouter,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const awardRouter = router({
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
routingMode: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
|
||||
routingMode: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
awardConfig: z.object({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
|
||||
|
||||
export const pipelineRouter = router({
|
||||
/**
|
||||
@@ -186,10 +186,6 @@ export const pipelineRouter = router({
|
||||
},
|
||||
},
|
||||
},
|
||||
routingRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: { priority: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
@@ -209,7 +205,7 @@ export const pipelineRouter = router({
|
||||
_count: { select: { stages: true, projectStageStates: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { tracks: true, routingRules: true } },
|
||||
_count: { select: { tracks: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -244,7 +240,7 @@ export const pipelineRouter = router({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { tracks: true, routingRules: true } },
|
||||
_count: { select: { tracks: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
@@ -327,7 +323,7 @@ export const pipelineRouter = router({
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
||||
sortOrder: z.number().int().min(0),
|
||||
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
|
||||
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
stages: z.array(
|
||||
z.object({
|
||||
@@ -399,40 +395,40 @@ export const pipelineRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Create stages for this track
|
||||
const createdStages: Array<{ id: string; name: string; sortOrder: number }> = []
|
||||
for (const stageInput of trackInput.stages) {
|
||||
let parsedConfig: Prisma.InputJsonValue | undefined
|
||||
if (stageInput.configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
stageInput.stageType,
|
||||
stageInput.configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfig = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Invalid config for stage ${stageInput.name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stage = await tx.stage.create({
|
||||
data: {
|
||||
trackId: track.id,
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
|
||||
// 3. Create stages for this track
|
||||
const createdStages: Array<{ id: string; name: string; sortOrder: number }> = []
|
||||
for (const stageInput of trackInput.stages) {
|
||||
let parsedConfig: Prisma.InputJsonValue | undefined
|
||||
if (stageInput.configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
stageInput.stageType,
|
||||
stageInput.configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfig = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Invalid config for stage ${stageInput.name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stage = await tx.stage.create({
|
||||
data: {
|
||||
trackId: track.id,
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
createdStages.push({ id: stage.id, name: stage.name, sortOrder: stage.sortOrder })
|
||||
}
|
||||
|
||||
// Create SpecialAward if AWARD kind
|
||||
@@ -524,32 +520,25 @@ export const pipelineRouter = router({
|
||||
},
|
||||
},
|
||||
},
|
||||
specialAward: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
criteriaText: true,
|
||||
useAiEligibility: true,
|
||||
scoringMode: true,
|
||||
maxRankedPicks: true,
|
||||
votingStartAt: true,
|
||||
votingEndAt: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
specialAward: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
criteriaText: true,
|
||||
useAiEligibility: true,
|
||||
scoringMode: true,
|
||||
maxRankedPicks: true,
|
||||
votingStartAt: true,
|
||||
votingEndAt: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { projectStageStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
routingRules: {
|
||||
orderBy: { priority: 'desc' },
|
||||
include: {
|
||||
sourceTrack: { select: { id: true, name: true } },
|
||||
destinationTrack: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -573,7 +562,7 @@ export const pipelineRouter = router({
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
kind: z.enum(['MAIN', 'AWARD', 'SHOWCASE']),
|
||||
sortOrder: z.number().int().min(0),
|
||||
routingModeDefault: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
|
||||
routingModeDefault: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
stages: z.array(
|
||||
z.object({
|
||||
@@ -738,52 +727,52 @@ export const pipelineRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update stages
|
||||
for (const stageInput of trackInput.stages) {
|
||||
let parsedConfig: Prisma.InputJsonValue | undefined
|
||||
if (stageInput.configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
stageInput.stageType,
|
||||
stageInput.configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfig = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Invalid config for stage ${stageInput.name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (stageInput.id) {
|
||||
await tx.stage.update({
|
||||
where: { id: stageInput.id },
|
||||
data: {
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
|
||||
} else {
|
||||
const newStage = await tx.stage.create({
|
||||
data: {
|
||||
trackId,
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
|
||||
// Create or update stages
|
||||
for (const stageInput of trackInput.stages) {
|
||||
let parsedConfig: Prisma.InputJsonValue | undefined
|
||||
if (stageInput.configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
stageInput.stageType,
|
||||
stageInput.configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfig = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Invalid config for stage ${stageInput.name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (stageInput.id) {
|
||||
await tx.stage.update({
|
||||
where: { id: stageInput.id },
|
||||
data: {
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
allStageIds.push({ id: stageInput.id, sortOrder: stageInput.sortOrder, trackId })
|
||||
} else {
|
||||
const newStage = await tx.stage.create({
|
||||
data: {
|
||||
trackId,
|
||||
name: stageInput.name,
|
||||
slug: stageInput.slug,
|
||||
stageType: stageInput.stageType,
|
||||
sortOrder: stageInput.sortOrder,
|
||||
configJson: parsedConfig,
|
||||
},
|
||||
})
|
||||
allStageIds.push({ id: newStage.id, sortOrder: stageInput.sortOrder, trackId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,10 +855,6 @@ export const pipelineRouter = router({
|
||||
tracks: {
|
||||
include: { stages: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
routingRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: { priority: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -895,26 +880,8 @@ export const pipelineRouter = router({
|
||||
// Simulate: for each project, determine which track/stage it would land in
|
||||
const mainTrack = pipeline.tracks.find((t) => t.kind === 'MAIN')
|
||||
const simulations = projects.map((project) => {
|
||||
// Default: route to first stage of MAIN track
|
||||
let targetTrack = mainTrack
|
||||
let targetStage = mainTrack?.stages[0] ?? null
|
||||
|
||||
// Check routing rules (highest priority first)
|
||||
for (const rule of pipeline.routingRules) {
|
||||
const predicate = rule.predicateJson as Record<string, unknown>
|
||||
if (predicate && evaluateSimplePredicate(predicate, project)) {
|
||||
const destTrack = pipeline.tracks.find(
|
||||
(t) => t.id === rule.destinationTrackId
|
||||
)
|
||||
if (destTrack) {
|
||||
targetTrack = destTrack
|
||||
targetStage = rule.destinationStageId
|
||||
? destTrack.stages.find((s) => s.id === rule.destinationStageId) ?? destTrack.stages[0]
|
||||
: destTrack.stages[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const targetTrack = mainTrack
|
||||
const targetStage = mainTrack?.stages[0] ?? null
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
@@ -1122,50 +1089,3 @@ export const pipelineRouter = router({
|
||||
|
||||
})
|
||||
|
||||
/**
|
||||
* Simple predicate evaluator for simulation.
|
||||
* Supports basic field matching on project data.
|
||||
*/
|
||||
function evaluateSimplePredicate(
|
||||
predicate: Record<string, unknown>,
|
||||
project: { tags: string[]; status: string; metadataJson: unknown }
|
||||
): boolean {
|
||||
const { field, operator, value } = predicate as {
|
||||
field?: string
|
||||
operator?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
if (!field || !operator) return false
|
||||
|
||||
let fieldValue: unknown
|
||||
|
||||
if (field === 'tags') {
|
||||
fieldValue = project.tags
|
||||
} else if (field === 'status') {
|
||||
fieldValue = project.status
|
||||
} else {
|
||||
// Check metadataJson
|
||||
const meta = (project.metadataJson as Record<string, unknown>) ?? {}
|
||||
fieldValue = meta[field]
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return fieldValue === value
|
||||
case 'contains':
|
||||
if (Array.isArray(fieldValue)) return fieldValue.includes(value)
|
||||
if (typeof fieldValue === 'string' && typeof value === 'string')
|
||||
return fieldValue.includes(value)
|
||||
return false
|
||||
case 'in':
|
||||
if (Array.isArray(value)) return value.includes(fieldValue)
|
||||
return false
|
||||
case 'hasAny':
|
||||
if (Array.isArray(fieldValue) && Array.isArray(value))
|
||||
return fieldValue.some((v) => value.includes(v))
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import {
|
||||
previewRouting,
|
||||
evaluateRoutingRules,
|
||||
executeRouting,
|
||||
} from '@/server/services/routing-engine'
|
||||
|
||||
export const routingRouter = router({
|
||||
/**
|
||||
* Preview routing: show where projects would land without executing.
|
||||
* Delegates to routing-engine service for proper predicate evaluation.
|
||||
*/
|
||||
preview: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const results = await previewRouting(
|
||||
input.projectIds,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
return {
|
||||
pipelineId: input.pipelineId,
|
||||
totalProjects: results.length,
|
||||
results: results.map((r) => ({
|
||||
projectId: r.projectId,
|
||||
projectTitle: r.projectTitle,
|
||||
matchedRuleId: r.matchedRule?.ruleId ?? null,
|
||||
matchedRuleName: r.matchedRule?.ruleName ?? null,
|
||||
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
|
||||
targetTrackName: null as string | null,
|
||||
targetStageId: r.matchedRule?.destinationStageId ?? null,
|
||||
targetStageName: null as string | null,
|
||||
routingMode: r.matchedRule?.routingMode ?? null,
|
||||
reason: r.reason,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute routing: evaluate rules and move projects into tracks/stages.
|
||||
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
|
||||
*/
|
||||
execute: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify pipeline is ACTIVE
|
||||
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: input.pipelineId },
|
||||
})
|
||||
|
||||
if (pipeline.status !== 'ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Pipeline must be ACTIVE to route projects',
|
||||
})
|
||||
}
|
||||
|
||||
// Load projects to get their current active stage states
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectStageStates: {
|
||||
where: { exitedAt: null },
|
||||
select: { stageId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No matching projects found',
|
||||
})
|
||||
}
|
||||
|
||||
let routedCount = 0
|
||||
let skippedCount = 0
|
||||
const errors: Array<{ projectId: string; error: string }> = []
|
||||
|
||||
for (const project of projects) {
|
||||
const activePSS = project.projectStageStates[0]
|
||||
if (!activePSS) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Evaluate routing rules using the service
|
||||
const matchedRule = await evaluateRoutingRules(
|
||||
project.id,
|
||||
activePSS.stageId,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (!matchedRule) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
|
||||
const result = await executeRouting(
|
||||
project.id,
|
||||
matchedRule,
|
||||
ctx.user.id,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
routedCount++
|
||||
} else {
|
||||
skippedCount++
|
||||
if (result.errors?.length) {
|
||||
errors.push({ projectId: project.id, error: result.errors[0] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record batch-level audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTING_EXECUTED',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
detailsJson: {
|
||||
projectCount: projects.length,
|
||||
routedCount,
|
||||
skippedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { routedCount, skippedCount, totalProjects: projects.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List routing rules for a pipeline
|
||||
*/
|
||||
listRules: adminProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.routingRule.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
|
||||
include: {
|
||||
sourceTrack: { select: { id: true, name: true } },
|
||||
destinationTrack: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update a routing rule
|
||||
*/
|
||||
upsertRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(), // If provided, update existing
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
scope: z.enum(['global', 'track', 'stage']).default('global'),
|
||||
sourceTrackId: z.string().optional().nullable(),
|
||||
destinationTrackId: z.string(),
|
||||
destinationStageId: z.string().optional().nullable(),
|
||||
predicateJson: z.record(z.unknown()),
|
||||
priority: z.number().int().min(0).max(1000).default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, predicateJson, ...data } = input
|
||||
|
||||
// Verify destination track exists in this pipeline
|
||||
const destTrack = await ctx.prisma.track.findFirst({
|
||||
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
|
||||
})
|
||||
if (!destTrack) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Destination track must belong to the same pipeline',
|
||||
})
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Update existing rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
} else {
|
||||
// Create new rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.routingRule.create({
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return rule
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a routing rule
|
||||
*/
|
||||
deleteRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, pipelineId: true },
|
||||
})
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.routingRule.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder routing rules by priority (highest first)
|
||||
*/
|
||||
reorderRules: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
orderedIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rules = await ctx.prisma.routingRule.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
select: { id: true },
|
||||
})
|
||||
const ruleIds = new Set(rules.map((rule) => rule.id))
|
||||
|
||||
for (const id of input.orderedIds) {
|
||||
if (!ruleIds.has(id)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Routing rule ${id} does not belong to this pipeline`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
const maxPriority = input.orderedIds.length
|
||||
await Promise.all(
|
||||
input.orderedIds.map((id, index) =>
|
||||
tx.routingRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
priority: maxPriority - index,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
detailsJson: {
|
||||
action: 'ROUTING_RULES_REORDERED',
|
||||
ruleCount: input.orderedIds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Toggle a routing rule on/off
|
||||
*/
|
||||
toggleRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
where: { id: input.id },
|
||||
data: { isActive: input.isActive },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: input.id,
|
||||
detailsJson: { isActive: input.isActive, name: updated.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
}),
|
||||
|
||||
/**
|
||||
* Parse natural language into a routing rule predicate using AI
|
||||
*/
|
||||
parseNaturalLanguageRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
pipelineId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'OpenAI is not configured. Go to Settings to set up the API key.',
|
||||
})
|
||||
}
|
||||
|
||||
// Load pipeline tracks for context
|
||||
const tracks = await ctx.prisma.track.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const trackNames = tracks.map((t) => t.name).join(', ')
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
const systemPrompt = `You are a routing rule parser for a project management pipeline.
|
||||
Convert the user's natural language description into a structured predicate JSON.
|
||||
|
||||
Available fields:
|
||||
- competitionCategory: The project's competition category (string values like "STARTUP", "BUSINESS_CONCEPT")
|
||||
- oceanIssue: The ocean issue the project addresses (string)
|
||||
- country: The project's country of origin (string)
|
||||
- geographicZone: The geographic zone (string)
|
||||
- wantsMentorship: Whether the project wants mentorship (boolean: true/false)
|
||||
- tags: Project tags (array of strings)
|
||||
|
||||
Available operators:
|
||||
- eq: equals (exact match)
|
||||
- neq: not equals
|
||||
- in: value is in a list
|
||||
- contains: string contains substring
|
||||
- gt: greater than (numeric)
|
||||
- lt: less than (numeric)
|
||||
|
||||
Predicate format:
|
||||
- Simple condition: { "field": "<field>", "operator": "<op>", "value": "<value>" }
|
||||
- Compound (AND): { "logic": "and", "conditions": [<condition>, ...] }
|
||||
- Compound (OR): { "logic": "or", "conditions": [<condition>, ...] }
|
||||
|
||||
For boolean fields (wantsMentorship), use value: true or value: false (not strings).
|
||||
For "in" operator, value should be an array: ["VALUE1", "VALUE2"].
|
||||
|
||||
Pipeline tracks: ${trackNames || 'None configured yet'}
|
||||
|
||||
Return a JSON object with two keys:
|
||||
- "predicate": the predicate JSON object
|
||||
- "explanation": a brief human-readable explanation of what the rule matches
|
||||
|
||||
Example input: "projects from France or Monaco that are startups"
|
||||
Example output:
|
||||
{
|
||||
"predicate": {
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
{ "field": "country", "operator": "in", "value": ["France", "Monaco"] },
|
||||
{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }
|
||||
]
|
||||
},
|
||||
"explanation": "Matches projects from France or Monaco with competition category STARTUP"
|
||||
}`
|
||||
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: input.text },
|
||||
],
|
||||
maxTokens: 1000,
|
||||
temperature: 0.1,
|
||||
jsonMode: true,
|
||||
})
|
||||
|
||||
const response = await openai.chat.completions.create(params)
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'AI returned an empty response',
|
||||
})
|
||||
}
|
||||
|
||||
// Log AI usage
|
||||
const tokenUsage = extractTokenUsage(response)
|
||||
await logAIUsage({
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTING',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
model,
|
||||
...tokenUsage,
|
||||
itemsProcessed: 1,
|
||||
status: 'SUCCESS',
|
||||
detailsJson: { input: input.text },
|
||||
})
|
||||
|
||||
// Parse the response
|
||||
let parsed: { predicate: Record<string, unknown>; explanation: string }
|
||||
try {
|
||||
parsed = JSON.parse(content) as { predicate: Record<string, unknown>; explanation: string }
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'AI returned invalid JSON. Try rephrasing your rule.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsed.predicate || typeof parsed.predicate !== 'object') {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'AI response missing predicate. Try rephrasing your rule.',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
predicateJson: parsed.predicate,
|
||||
explanation: parsed.explanation || 'Parsed routing rule',
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,505 +0,0 @@
|
||||
/**
|
||||
* Routing Engine Service
|
||||
*
|
||||
* Evaluates routing rules against projects and executes routing decisions
|
||||
* to move projects between tracks in a pipeline. Supports three routing modes:
|
||||
*
|
||||
* - PARALLEL: Keep the project in the current track AND add it to the destination track
|
||||
* - EXCLUSIVE: Exit the project from the current track and move to the destination track
|
||||
* - POST_MAIN: Route to destination only after the main track gate is passed
|
||||
*
|
||||
* Predicate evaluation supports operators: eq, neq, in, contains, gt, lt
|
||||
* Compound predicates: and, or
|
||||
*/
|
||||
|
||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PredicateLeaf {
|
||||
field: string
|
||||
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PredicateCompound {
|
||||
logic: 'and' | 'or'
|
||||
conditions: PredicateNode[]
|
||||
}
|
||||
|
||||
type PredicateNode = PredicateLeaf | PredicateCompound
|
||||
|
||||
export interface MatchedRule {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
destinationTrackId: string
|
||||
destinationStageId: string | null
|
||||
routingMode: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface RoutingPreviewItem {
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
matchedRule: MatchedRule | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RoutingExecutionResult {
|
||||
success: boolean
|
||||
projectStageStateId: string | null
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
// ─── Predicate Evaluation ───────────────────────────────────────────────────
|
||||
|
||||
function isCompoundPredicate(node: unknown): node is PredicateCompound {
|
||||
return (
|
||||
typeof node === 'object' &&
|
||||
node !== null &&
|
||||
'logic' in node &&
|
||||
'conditions' in node
|
||||
)
|
||||
}
|
||||
|
||||
function isLeafPredicate(node: unknown): node is PredicateLeaf {
|
||||
return (
|
||||
typeof node === 'object' &&
|
||||
node !== null &&
|
||||
'field' in node &&
|
||||
'operator' in node
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateLeaf(
|
||||
leaf: PredicateLeaf,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
const fieldValue = resolveField(context, leaf.field)
|
||||
|
||||
switch (leaf.operator) {
|
||||
case 'eq':
|
||||
return fieldValue === leaf.value
|
||||
case 'neq':
|
||||
return fieldValue !== leaf.value
|
||||
case 'in': {
|
||||
if (!Array.isArray(leaf.value)) return false
|
||||
return leaf.value.includes(fieldValue)
|
||||
}
|
||||
case 'contains': {
|
||||
if (typeof fieldValue === 'string' && typeof leaf.value === 'string') {
|
||||
return fieldValue.toLowerCase().includes(leaf.value.toLowerCase())
|
||||
}
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.includes(leaf.value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'gt':
|
||||
return Number(fieldValue) > Number(leaf.value)
|
||||
case 'lt':
|
||||
return Number(fieldValue) < Number(leaf.value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dot-notation field path from a context object.
|
||||
* E.g. "project.country" resolves context.project.country
|
||||
*/
|
||||
function resolveField(
|
||||
context: Record<string, unknown>,
|
||||
fieldPath: string
|
||||
): unknown {
|
||||
const parts = fieldPath.split('.')
|
||||
let current: unknown = context
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined
|
||||
if (typeof current !== 'object') return undefined
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
function evaluatePredicate(
|
||||
node: PredicateNode,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
if (isCompoundPredicate(node)) {
|
||||
const results = node.conditions.map((child) =>
|
||||
evaluatePredicate(child, context)
|
||||
)
|
||||
return node.logic === 'and'
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean)
|
||||
}
|
||||
|
||||
if (isLeafPredicate(node)) {
|
||||
return evaluateLeaf(node, context)
|
||||
}
|
||||
|
||||
// Unknown node type, fail closed
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Build Project Context ──────────────────────────────────────────────────
|
||||
|
||||
async function buildProjectContext(
|
||||
projectId: string,
|
||||
currentStageId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<Record<string, unknown>> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: { select: { fileType: true, fileName: true } },
|
||||
projectTags: { include: { tag: true } },
|
||||
filteringResults: {
|
||||
where: { stageId: currentStageId },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
},
|
||||
assignments: {
|
||||
where: { stageId: currentStageId },
|
||||
include: { evaluation: true },
|
||||
},
|
||||
projectStageStates: {
|
||||
where: { stageId: currentStageId, exitedAt: null },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) return {}
|
||||
|
||||
const evaluations = project.assignments
|
||||
.map((a: any) => a.evaluation)
|
||||
.filter(Boolean)
|
||||
const submittedEvals = evaluations.filter(
|
||||
(e: any) => e.status === 'SUBMITTED'
|
||||
)
|
||||
const avgScore =
|
||||
submittedEvals.length > 0
|
||||
? submittedEvals.reduce(
|
||||
(sum: number, e: any) => sum + (e.globalScore ?? 0),
|
||||
0
|
||||
) / submittedEvals.length
|
||||
: 0
|
||||
|
||||
const filteringResult = project.filteringResults[0] ?? null
|
||||
const currentPSS = project.projectStageStates[0] ?? null
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
country: project.country,
|
||||
competitionCategory: project.competitionCategory,
|
||||
oceanIssue: project.oceanIssue,
|
||||
tags: project.tags,
|
||||
wantsMentorship: project.wantsMentorship,
|
||||
fileCount: project.files.length,
|
||||
},
|
||||
tags: project.projectTags.map((pt: any) => pt.tag.name),
|
||||
evaluation: {
|
||||
count: evaluations.length,
|
||||
submittedCount: submittedEvals.length,
|
||||
averageScore: avgScore,
|
||||
},
|
||||
filtering: {
|
||||
outcome: filteringResult?.outcome ?? null,
|
||||
finalOutcome: filteringResult?.finalOutcome ?? null,
|
||||
},
|
||||
state: currentPSS?.state ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Evaluate Routing Rules ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load active routing rules for a pipeline, evaluate predicates against a
|
||||
* project's context, and return the first matching rule (by priority, lowest first).
|
||||
*/
|
||||
export async function evaluateRoutingRules(
|
||||
projectId: string,
|
||||
currentStageId: string,
|
||||
pipelineId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<MatchedRule | null> {
|
||||
const rules = await prisma.routingRule.findMany({
|
||||
where: {
|
||||
pipelineId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
destinationTrack: true,
|
||||
sourceTrack: true,
|
||||
},
|
||||
orderBy: { priority: 'asc' as const },
|
||||
})
|
||||
|
||||
if (rules.length === 0) return null
|
||||
|
||||
const context = await buildProjectContext(projectId, currentStageId, prisma)
|
||||
|
||||
for (const rule of rules) {
|
||||
// If rule has a sourceTrackId, check that the project is in that track
|
||||
if (rule.sourceTrackId) {
|
||||
const inSourceTrack = await prisma.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
trackId: rule.sourceTrackId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
if (!inSourceTrack) continue
|
||||
}
|
||||
|
||||
const predicateJson = rule.predicateJson as unknown as PredicateNode
|
||||
if (evaluatePredicate(predicateJson, context)) {
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
destinationTrackId: rule.destinationTrackId,
|
||||
destinationStageId: rule.destinationStageId ?? null,
|
||||
routingMode: rule.destinationTrack.routingMode ?? 'EXCLUSIVE',
|
||||
priority: rule.priority,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Execute Routing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a routing decision for a project based on the matched rule.
|
||||
*
|
||||
* PARALLEL mode: Keep the project in its current track, add a new PSS in the
|
||||
* destination track's first stage (or specified destination stage).
|
||||
* EXCLUSIVE mode: Exit the current PSS and create a new PSS in the destination.
|
||||
* POST_MAIN mode: Validate that the project PASSED the main track gate before routing.
|
||||
*/
|
||||
export async function executeRouting(
|
||||
projectId: string,
|
||||
matchedRule: MatchedRule,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<RoutingExecutionResult> {
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const now = new Date()
|
||||
|
||||
// Determine destination stage
|
||||
let destinationStageId = matchedRule.destinationStageId
|
||||
if (!destinationStageId) {
|
||||
// Find the first stage in the destination track (by sortOrder)
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: matchedRule.destinationTrackId },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
})
|
||||
if (!firstStage) {
|
||||
throw new Error(
|
||||
`No stages found in destination track ${matchedRule.destinationTrackId}`
|
||||
)
|
||||
}
|
||||
destinationStageId = firstStage.id
|
||||
}
|
||||
|
||||
// Mode-specific logic
|
||||
if (matchedRule.routingMode === 'POST_MAIN') {
|
||||
// Validate that the project has passed the main track gate
|
||||
const mainTrack = await tx.track.findFirst({
|
||||
where: {
|
||||
pipeline: {
|
||||
tracks: {
|
||||
some: { id: matchedRule.destinationTrackId },
|
||||
},
|
||||
},
|
||||
kind: 'MAIN',
|
||||
},
|
||||
})
|
||||
|
||||
if (mainTrack) {
|
||||
const mainPSS = await tx.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
trackId: mainTrack.id,
|
||||
state: { in: ['PASSED', 'COMPLETED'] },
|
||||
},
|
||||
orderBy: { exitedAt: 'desc' as const },
|
||||
})
|
||||
|
||||
if (!mainPSS) {
|
||||
throw new Error(
|
||||
'POST_MAIN routing requires the project to have passed the main track gate'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedRule.routingMode === 'EXCLUSIVE') {
|
||||
// Exit all active PSS for this project in any track of the same pipeline
|
||||
const activePSSRecords = await tx.projectStageState.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
for (const pss of activePSSRecords) {
|
||||
await tx.projectStageState.update({
|
||||
where: { id: pss.id },
|
||||
data: {
|
||||
exitedAt: now,
|
||||
state: 'ROUTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create PSS in destination track/stage
|
||||
const destPSS = await tx.projectStageState.upsert({
|
||||
where: {
|
||||
projectId_trackId_stageId: {
|
||||
projectId,
|
||||
trackId: matchedRule.destinationTrackId,
|
||||
stageId: destinationStageId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
trackId: matchedRule.destinationTrackId,
|
||||
stageId: destinationStageId,
|
||||
state: 'PENDING',
|
||||
enteredAt: now,
|
||||
},
|
||||
update: {
|
||||
state: 'PENDING',
|
||||
enteredAt: now,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Log DecisionAuditLog
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'routing.executed',
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: destPSS.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
ruleId: matchedRule.ruleId,
|
||||
ruleName: matchedRule.ruleName,
|
||||
routingMode: matchedRule.routingMode,
|
||||
destinationTrackId: matchedRule.destinationTrackId,
|
||||
destinationStageId,
|
||||
},
|
||||
snapshotJson: {
|
||||
destPSSId: destPSS.id,
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// AuditLog
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'ROUTING_EXECUTED',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: matchedRule.ruleId,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
routingMode: matchedRule.routingMode,
|
||||
destinationTrackId: matchedRule.destinationTrackId,
|
||||
destinationStageId,
|
||||
},
|
||||
})
|
||||
|
||||
return destPSS
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectStageStateId: result.id,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RoutingEngine] Routing execution failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
projectStageStateId: null,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error during routing execution',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preview Routing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dry-run evaluation of routing rules for a batch of projects.
|
||||
* Does not modify any data.
|
||||
*/
|
||||
export async function previewRouting(
|
||||
projectIds: string[],
|
||||
pipelineId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<RoutingPreviewItem[]> {
|
||||
const preview: RoutingPreviewItem[] = []
|
||||
|
||||
// Load projects with their current stage states
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectStageStates: {
|
||||
where: { exitedAt: null },
|
||||
select: { stageId: true, trackId: true, state: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const project of projects) {
|
||||
const activePSS = project.projectStageStates[0]
|
||||
|
||||
if (!activePSS) {
|
||||
preview.push({
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
matchedRule: null,
|
||||
reason: 'No active stage state found',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedRule = await evaluateRoutingRules(
|
||||
project.id,
|
||||
activePSS.stageId,
|
||||
pipelineId,
|
||||
prisma
|
||||
)
|
||||
|
||||
preview.push({
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
matchedRule,
|
||||
reason: matchedRule
|
||||
? `Matched rule "${matchedRule.ruleName}" (priority ${matchedRule.priority})`
|
||||
: 'No routing rules matched',
|
||||
})
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
@@ -261,6 +261,34 @@ export async function runStageFiltering(
|
||||
)
|
||||
const aiRules = rules.filter((r: any) => r.ruleType === 'AI_SCREENING')
|
||||
|
||||
// ── Built-in: Duplicate submission detection ──────────────────────────────
|
||||
// Group projects by submitter email to detect duplicate submissions.
|
||||
// Duplicates are ALWAYS flagged for admin review (never auto-rejected).
|
||||
const duplicateProjectIds = new Set<string>()
|
||||
const emailToProjects = new Map<string, Array<{ id: string; title: string }>>()
|
||||
|
||||
for (const project of projects) {
|
||||
const email = (project.submittedByEmail ?? '').toLowerCase().trim()
|
||||
if (!email) continue
|
||||
if (!emailToProjects.has(email)) emailToProjects.set(email, [])
|
||||
emailToProjects.get(email)!.push({ id: project.id, title: project.title })
|
||||
}
|
||||
|
||||
const duplicateGroups: Map<string, string[]> = new Map() // projectId → sibling ids
|
||||
emailToProjects.forEach((group, _email) => {
|
||||
if (group.length <= 1) return
|
||||
const ids = group.map((p) => p.id)
|
||||
for (const p of group) {
|
||||
duplicateProjectIds.add(p.id)
|
||||
duplicateGroups.set(p.id, ids.filter((id) => id !== p.id))
|
||||
}
|
||||
})
|
||||
|
||||
if (duplicateProjectIds.size > 0) {
|
||||
console.log(`[Stage Filtering] Detected ${duplicateProjectIds.size} projects in duplicate groups`)
|
||||
}
|
||||
// ── End duplicate detection ───────────────────────────────────────────────
|
||||
|
||||
let passed = 0
|
||||
let rejected = 0
|
||||
let manualQueue = 0
|
||||
@@ -271,6 +299,20 @@ export async function runStageFiltering(
|
||||
let deterministicPassed = true
|
||||
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
|
||||
|
||||
// 0. Check for duplicate submissions (always FLAG, never auto-reject)
|
||||
if (duplicateProjectIds.has(project.id)) {
|
||||
const siblingIds = duplicateGroups.get(project.id) ?? []
|
||||
ruleResults.push({
|
||||
ruleId: '__duplicate_check',
|
||||
ruleName: 'Duplicate Submission Check',
|
||||
ruleType: 'DUPLICATE_CHECK',
|
||||
passed: false,
|
||||
action: 'FLAG',
|
||||
reasoning: `Duplicate submission detected: same applicant email submitted ${siblingIds.length + 1} project(s). Sibling project IDs: ${siblingIds.join(', ')}. Admin must review and decide which to keep.`,
|
||||
})
|
||||
deterministicOutcome = 'FLAGGED'
|
||||
}
|
||||
|
||||
// 1. Run deterministic rules
|
||||
for (const rule of deterministicRules) {
|
||||
const config = rule.configJson as unknown as RuleConfig
|
||||
@@ -312,11 +354,12 @@ export async function runStageFiltering(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI screening (only if deterministic passed)
|
||||
// 2. AI screening (run if deterministic passed, OR if duplicate—so AI can recommend which to keep)
|
||||
const isDuplicate = duplicateProjectIds.has(project.id)
|
||||
let aiScreeningJson: Record<string, unknown> | null = null
|
||||
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
|
||||
|
||||
if (deterministicPassed && aiRules.length > 0) {
|
||||
if ((deterministicPassed || isDuplicate) && aiRules.length > 0) {
|
||||
// Build a simplified AI screening result using the existing AI criteria
|
||||
// In production this would call OpenAI via the ai-filtering service
|
||||
const aiRule = aiRules[0]
|
||||
@@ -337,12 +380,25 @@ export async function runStageFiltering(
|
||||
: 'Insufficient project data for AI screening',
|
||||
}
|
||||
|
||||
// Attach duplicate metadata so admin can see sibling projects
|
||||
if (isDuplicate) {
|
||||
const siblingIds = duplicateGroups.get(project.id) ?? []
|
||||
aiScreeningJson.isDuplicate = true
|
||||
aiScreeningJson.siblingProjectIds = siblingIds
|
||||
aiScreeningJson.duplicateNote =
|
||||
`This project shares a submitter email with ${siblingIds.length} other project(s). ` +
|
||||
'AI screening should compare these and recommend which to keep.'
|
||||
}
|
||||
|
||||
const banded = bandByConfidence({
|
||||
confidence,
|
||||
meetsAllCriteria: hasMinimalData,
|
||||
})
|
||||
|
||||
finalOutcome = banded.outcome
|
||||
// For non-duplicate projects, use AI banding; for duplicates, keep FLAGGED
|
||||
if (!isDuplicate) {
|
||||
finalOutcome = banded.outcome
|
||||
}
|
||||
|
||||
ruleResults.push({
|
||||
ruleId: aiRule.id,
|
||||
@@ -354,6 +410,12 @@ export async function runStageFiltering(
|
||||
})
|
||||
}
|
||||
|
||||
// Duplicate submissions must ALWAYS be flagged for admin review,
|
||||
// even if other rules would auto-reject them.
|
||||
if (duplicateProjectIds.has(project.id) && finalOutcome === 'FILTERED_OUT') {
|
||||
finalOutcome = 'FLAGGED'
|
||||
}
|
||||
|
||||
await prisma.filteringResult.upsert({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* Event types follow a dotted convention:
|
||||
* stage.transitioned, filtering.completed, assignment.generated,
|
||||
* routing.executed, live.cursor_updated, decision.overridden
|
||||
* live.cursor_updated, decision.overridden
|
||||
*/
|
||||
|
||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||
@@ -32,7 +32,6 @@ const EVENT_TYPES = {
|
||||
STAGE_TRANSITIONED: 'stage.transitioned',
|
||||
FILTERING_COMPLETED: 'filtering.completed',
|
||||
ASSIGNMENT_GENERATED: 'assignment.generated',
|
||||
ROUTING_EXECUTED: 'routing.executed',
|
||||
CURSOR_UPDATED: 'live.cursor_updated',
|
||||
DECISION_OVERRIDDEN: 'decision.overridden',
|
||||
} as const
|
||||
@@ -41,7 +40,6 @@ const EVENT_TITLES: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'Routing Executed',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
|
||||
}
|
||||
@@ -50,7 +48,6 @@ const EVENT_ICONS: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'GitBranch',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
|
||||
}
|
||||
@@ -59,7 +56,6 @@ const EVENT_PRIORITIES: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'normal',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
|
||||
}
|
||||
@@ -220,7 +216,6 @@ async function resolveRecipients(
|
||||
case EVENT_TYPES.STAGE_TRANSITIONED:
|
||||
case EVENT_TYPES.FILTERING_COMPLETED:
|
||||
case EVENT_TYPES.ASSIGNMENT_GENERATED:
|
||||
case EVENT_TYPES.ROUTING_EXECUTED:
|
||||
case EVENT_TYPES.DECISION_OVERRIDDEN: {
|
||||
// Notify admins
|
||||
const admins = await prisma.user.findMany({
|
||||
@@ -311,12 +306,6 @@ function buildNotificationMessage(
|
||||
return `${count ?? 0} assignments were generated for the stage.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.ROUTING_EXECUTED: {
|
||||
const ruleName = details.ruleName as string | undefined
|
||||
const routingMode = details.routingMode as string | undefined
|
||||
return `Routing rule "${ruleName ?? 'unknown'}" executed in ${routingMode ?? 'unknown'} mode.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.CURSOR_UPDATED: {
|
||||
const projectId = details.projectId as string | undefined
|
||||
const action = details.action as string | undefined
|
||||
@@ -419,34 +408,6 @@ export async function onAssignmentGenerated(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a routing.executed event when a project is routed to a new track.
|
||||
* Called from routing-engine.ts after executeRouting.
|
||||
*/
|
||||
export async function onRoutingExecuted(
|
||||
ruleId: string,
|
||||
projectId: string,
|
||||
ruleName: string,
|
||||
routingMode: string,
|
||||
destinationTrackId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.ROUTING_EXECUTED,
|
||||
'RoutingRule',
|
||||
ruleId,
|
||||
actorId,
|
||||
{
|
||||
projectId,
|
||||
ruleName,
|
||||
routingMode,
|
||||
destinationTrackId,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a live.cursor_updated event when the live cursor position changes.
|
||||
* Called from live-control.ts after setActiveProject or jumpToProject.
|
||||
|
||||
Reference in New Issue
Block a user