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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user