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 }), })