384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
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 {
|
|
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
|
|
}),
|
|
})
|