From f89fb73affc6692854e2caa3f8284a88625320d2 Mon Sep 17 00:00:00 2001 From: DJP Date: Tue, 21 Apr 2026 13:19:43 -0400 Subject: [PATCH] Pipeline editor: rework paths (pass/fail loops) now configurable in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can draw backward transitions on the same ReactFlow canvas that already handles forward dependencies. With this, the pipeline editor is a full workflow builder — new pipelines for new clients can be assembled in the browser without code changes. How it works: - "Workflow Graph" panel now has a mode toggle: Dependencies / Rework paths. Both edge types always render — the toggle just scopes what dragging between stages creates. - Forward (solid) and rework (dashed red) edges coexist on the canvas; arrows point source→target in both cases. - Drag validation: rework drops where target.order >= source.order are rejected client-side (silent — a round-trip error would just confirm the same thing). - Click the × on any edge to delete it. Plumbing: - pipeline-template-service: addReworkPath / removeReworkPath with same-pipeline + not-self + strictly-backward checks. getPipelineTemplate includes reworkFrom + reworkTo so the canvas renders reworks alongside dependencies without a second fetch. - POST/DELETE /api/pipelines/:id/reworks - useAddRework / useRemoveRework hooks - New ReworkEdge component — dashed, red, curvier arc to keep pass/fail visually distinct from the sequential forward flow --- .../settings/pipelines/[pipelineId]/page.tsx | 72 ++++++++++++-- .../pipelines/[pipelineId]/reworks/route.ts | 67 +++++++++++++ .../pipeline-builder/pipeline-graph.tsx | 99 ++++++++++++++++--- .../pipeline-builder/rework-edge.tsx | 87 ++++++++++++++++ src/hooks/use-pipelines.ts | 20 ++++ src/lib/services/pipeline-template-service.ts | 69 +++++++++++++ 6 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 src/app/api/pipelines/[pipelineId]/reworks/route.ts create mode 100644 src/components/pipeline-builder/rework-edge.tsx diff --git a/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx index e5bd250..718c28d 100644 --- a/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx +++ b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx @@ -2,7 +2,7 @@ import { use, useState } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, GitBranch, Plus, Star } from "lucide-react"; +import { ArrowLeft, GitBranch, Plus, Star, Workflow, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -23,11 +23,13 @@ import { useReorderStages, useAddDependency, useRemoveDependency, + useAddRework, + useRemoveRework, usePipelineValidation, } from "@/hooks/use-pipelines"; import { usePipelineBuilderStore } from "@/stores/pipeline-builder-store"; import { PipelineStageList } from "@/components/pipeline-builder/pipeline-stage-list"; -import { PipelineGraph } from "@/components/pipeline-builder/pipeline-graph"; +import { PipelineGraph, type PipelineGraphMode } from "@/components/pipeline-builder/pipeline-graph"; import { StageEditSheet } from "@/components/pipeline-builder/stage-edit-sheet"; import { PipelineValidationBanner } from "@/components/pipeline-builder/pipeline-validation-banner"; import { toast } from "sonner"; @@ -47,10 +49,16 @@ export default function PipelineEditorPage({ params }: PageProps) { const reorderStages = useReorderStages(pipelineId); const addDependency = useAddDependency(pipelineId); const removeDependency = useRemoveDependency(pipelineId); + const addRework = useAddRework(pipelineId); + const removeRework = useRemoveRework(pipelineId); const { selectedStageId, selectStage } = usePipelineBuilderStore(); const [showAddStage, setShowAddStage] = useState(false); const [newStageName, setNewStageName] = useState(""); + // Which edge type dragging between stage handles will create. + // Both edge types are ALWAYS rendered on the canvas so the full + // workflow is visible; this just scopes the drag target. + const [graphMode, setGraphMode] = useState("dependency"); const pl = pipeline as any; const stages = (pl?.stages ?? []) as any[]; @@ -119,6 +127,24 @@ export default function PipelineEditorPage({ params }: PageProps) { } }; + const handleConnectRework = async (fromStageId: string, toStageId: string) => { + try { + await addRework.mutateAsync({ fromStageId, toStageId }); + toast.success("Rework path added"); + } catch (e: any) { + toast.error(e.message || "Failed to add rework path"); + } + }; + + const handleDeleteRework = async (fromStageId: string, toStageId: string) => { + try { + await removeRework.mutateAsync({ fromStageId, toStageId }); + toast.success("Rework path removed"); + } catch (e: any) { + toast.error(e.message || "Failed to remove rework path"); + } + }; + const handleToggleDefault = async () => { try { await updatePipeline.mutateAsync({ isDefault: !pl?.isDefault }); @@ -217,18 +243,52 @@ export default function PipelineEditorPage({ params }: PageProps) { /> - {/* Right: Dependency graph */} + {/* Right: Dependency + rework graph */}
-
+
- Dependency Graph + Workflow Graph +
+ + +
-
+
+ {graphMode === "dependency" ? ( + <>Drag from one stage to another to add a forward prerequisite. Click the × on a solid line to remove. + ) : ( + <>Drag from a later stage to an earlier one to add a rework (pushback) path — e.g., Final Approval → In Progress. Drawn as dashed red. Click × to remove. + )} +
+
diff --git a/src/app/api/pipelines/[pipelineId]/reworks/route.ts b/src/app/api/pipelines/[pipelineId]/reworks/route.ts new file mode 100644 index 0000000..d50679a --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/reworks/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + addReworkPath, + removeReworkPath, +} from "@/lib/services/pipeline-template-service"; + +// Body for POST — both stage ids must belong to this pipeline. +// Direction is validated in the service (toStage.order < fromStage.order). +const bodySchema = z.object({ + fromStageId: z.string().min(1), + toStageId: z.string().min(1), +}); + +// POST — add a rework (pushback) path between stages +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const rework = await addReworkPath( + pipelineId, + parsed.data.fromStageId, + parsed.data.toStageId, + session.user.organizationId + ); + return NextResponse.json(rework, { status: 201 }); + } catch (e) { + return serverError(e); + } +} + +// DELETE — remove a rework path +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + await removeReworkPath( + pipelineId, + parsed.data.fromStageId, + parsed.data.toStageId, + session.user.organizationId + ); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/pipeline-builder/pipeline-graph.tsx b/src/components/pipeline-builder/pipeline-graph.tsx index 5a67114..791589f 100644 --- a/src/components/pipeline-builder/pipeline-graph.tsx +++ b/src/components/pipeline-builder/pipeline-graph.tsx @@ -17,6 +17,9 @@ import "@xyflow/react/dist/style.css"; import { StageNode } from "./stage-node"; import { DependencyEdge } from "./dependency-edge"; +import { ReworkEdge } from "./rework-edge"; + +export type PipelineGraphMode = "dependency" | "rework"; interface PipelineGraphProps { stages: Array<{ @@ -28,14 +31,23 @@ interface PipelineGraphProps { isOptional: boolean; color: string | null; dependsOn: Array<{ prerequisiteId: string }>; + /** Rework edges originating at this stage — `toStageId` is the + * pushback target (must have a lower `order`). Included in the + * getPipelineTemplate response. */ + reworkFrom?: Array<{ toStageId: string }>; }>; + /** Which edge type the user is currently drawing. Both types are + * always rendered — this just scopes drag behaviour. */ + mode: PipelineGraphMode; onConnect: (stageId: string, prerequisiteId: string) => void; onDeleteEdge: (stageId: string, prerequisiteId: string) => void; + onConnectRework: (fromStageId: string, toStageId: string) => void; + onDeleteRework: (fromStageId: string, toStageId: string) => void; onSelectStage: (id: string) => void; } const nodeTypes = { stage: StageNode }; -const edgeTypes = { dependency: DependencyEdge }; +const edgeTypes = { dependency: DependencyEdge, rework: ReworkEdge }; function buildLayout( stages: PipelineGraphProps["stages"] @@ -111,7 +123,7 @@ function buildLayout( for (const stage of sorted) { for (const dep of stage.dependsOn) { edges.push({ - id: `${dep.prerequisiteId}->${stage.id}`, + id: `dep:${dep.prerequisiteId}->${stage.id}`, source: dep.prerequisiteId, target: stage.id, type: "dependency", @@ -121,6 +133,20 @@ function buildLayout( }, }); } + // Rework edges — source is the "from" stage (where work currently + // sits), target is the stage we're pushing back to. + for (const rw of stage.reworkFrom ?? []) { + edges.push({ + id: `rew:${stage.id}->${rw.toStageId}`, + source: stage.id, + target: rw.toStageId, + type: "rework", + data: { + fromStageId: stage.id, + toStageId: rw.toStageId, + }, + }); + } } return { nodes, edges }; @@ -128,8 +154,11 @@ function buildLayout( function PipelineGraphInner({ stages, + mode, onConnect, onDeleteEdge, + onConnectRework, + onDeleteRework, onSelectStage, }: PipelineGraphProps) { const { nodes: layoutNodes, edges: layoutEdges } = useMemo( @@ -137,20 +166,38 @@ function PipelineGraphInner({ [stages] ); + // Delete handlers vary by edge type — bind each edge's onDelete to + // the right callback so the UI doesn't need to know which type it's + // looking at. const edgesWithHandlers = useMemo( () => - layoutEdges.map((edge) => ({ - ...edge, - data: { - ...edge.data, - onDelete: () => - onDeleteEdge( - edge.data?.stageId as string, - edge.data?.prerequisiteId as string - ), - }, - })), - [layoutEdges, onDeleteEdge] + layoutEdges.map((edge) => { + if (edge.type === "rework") { + return { + ...edge, + data: { + ...edge.data, + onDelete: () => + onDeleteRework( + edge.data?.fromStageId as string, + edge.data?.toStageId as string + ), + }, + }; + } + return { + ...edge, + data: { + ...edge.data, + onDelete: () => + onDeleteEdge( + edge.data?.stageId as string, + edge.data?.prerequisiteId as string + ), + }, + }; + }), + [layoutEdges, onDeleteEdge, onDeleteRework] ); const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes); @@ -166,13 +213,33 @@ function PipelineGraphInner({ requestAnimationFrame(() => fitView({ padding: 0.3 })); }, [layoutNodes, edgesWithHandlers, setNodes, setEdges, fitView]); + // Stage order lookup for client-side rework direction check — we + // still validate on the server, but rejecting a bad drag locally + // gives instant feedback rather than a round-trip error toast. + const orderById = useMemo( + () => new Map(stages.map((s) => [s.id, s.order])), + [stages] + ); + const handleConnect = useCallback( (connection: Connection) => { - if (connection.source && connection.target) { + if (!connection.source || !connection.target) return; + if (mode === "rework") { + const srcOrder = orderById.get(connection.source) ?? -1; + const tgtOrder = orderById.get(connection.target) ?? -1; + if (tgtOrder >= srcOrder) { + // Silent reject — rework must go backward. The server would + // return the same error; this keeps the UI snappy. + return; + } + onConnectRework(connection.source, connection.target); + } else { + // Dependency: source is the prerequisite, target is the stage + // that depends on it (preserves the original signature). onConnect(connection.target, connection.source); } }, - [onConnect] + [mode, onConnect, onConnectRework, orderById] ); const handleNodeClick = useCallback( diff --git a/src/components/pipeline-builder/rework-edge.tsx b/src/components/pipeline-builder/rework-edge.tsx new file mode 100644 index 0000000..9458a40 --- /dev/null +++ b/src/components/pipeline-builder/rework-edge.tsx @@ -0,0 +1,87 @@ +"use client"; + +/** + * Rework edge (backward/rejection path) for the pipeline canvas. + * + * Visually distinct from dependency edges — dashed, primary-accent + * colored, curved wider so it reads as a loop rather than a sequence. + * Arrow still points toward the target (the stage we push back to), + * which matches ReactFlow's source→target direction convention. + */ + +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + type EdgeProps, +} from "@xyflow/react"; +import { X } from "lucide-react"; + +interface ReworkEdgeData { + onDelete?: () => void; + [key: string]: unknown; +} + +export function ReworkEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, +}: EdgeProps) { + // Give rework edges a steeper curve so they arc over the top/bottom + // of the canvas rather than overlapping the forward edges — visual + // separation makes pass vs fail paths readable at a glance. + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.6, + }); + + const edgeData = data as ReworkEdgeData | undefined; + // Red-ish hue signals "this is a rejection / backward loop" without + // clashing with the theme's brand (Dow navy) or error (destructive). + const stroke = "#DC2626"; + + return ( + <> + + + {edgeData?.onDelete && ( + + + + )} + + ); +} diff --git a/src/hooks/use-pipelines.ts b/src/hooks/use-pipelines.ts index 5451899..c75feac 100644 --- a/src/hooks/use-pipelines.ts +++ b/src/hooks/use-pipelines.ts @@ -160,6 +160,26 @@ export function useRemoveDependency(pipelineId: string) { }); } +// ─── Rework (backward) paths ──────────────────────────── + +export function useAddRework(pipelineId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { fromStageId: string; toStageId: string }) => + jsonPost(`/api/pipelines/${pipelineId}/reworks`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }), + }); +} + +export function useRemoveRework(pipelineId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { fromStageId: string; toStageId: string }) => + jsonDelete(`/api/pipelines/${pipelineId}/reworks`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }), + }); +} + // ─── Validation ───────────────────────────────────────── export function usePipelineValidation(pipelineId: string) { diff --git a/src/lib/services/pipeline-template-service.ts b/src/lib/services/pipeline-template-service.ts index ea7a956..93fe616 100644 --- a/src/lib/services/pipeline-template-service.ts +++ b/src/lib/services/pipeline-template-service.ts @@ -29,6 +29,10 @@ export async function getPipelineTemplate(id: string, orgId: string) { include: { dependsOn: { include: { prerequisite: true } }, dependedBy: { include: { stage: true } }, + // Rework edges are read alongside forward deps so the canvas + // can render both without a second round-trip. + reworkFrom: { include: { toStage: true } }, + reworkTo: { include: { fromStage: true } }, }, }, _count: { select: { projects: true } }, @@ -327,6 +331,71 @@ export async function removeDependency( }); } +// ─── Rework (backward) paths ──────────────────────────── +// +// A rework edge says "from stage X you may push a deliverable back to +// stage Y". Unlike dependsOn (which is about forward prerequisites), +// reworks are strictly backward and opt-in — no rework edge = no +// backward drag allowed on the board. + +export async function addReworkPath( + pipelineId: string, + fromStageId: string, + toStageId: string, + orgId: string +) { + const pipeline = await prisma.pipelineTemplate.findFirst({ + where: { id: pipelineId, organizationId: orgId }, + select: { id: true }, + }); + if (!pipeline) throw new Error("Pipeline template not found"); + + if (fromStageId === toStageId) { + throw new Error("A stage can't rework back to itself"); + } + + // Both stages must live on this pipeline, and target must come + // earlier (lower order). Rework is explicitly backward; forward is + // what dependsOn is for. + const stages = await prisma.pipelineStageDefinition.findMany({ + where: { pipelineId, id: { in: [fromStageId, toStageId] } }, + select: { id: true, order: true, name: true }, + }); + if (stages.length !== 2) { + throw new Error("Both stages must belong to this pipeline"); + } + const fromStage = stages.find((s) => s.id === fromStageId)!; + const toStage = stages.find((s) => s.id === toStageId)!; + if (toStage.order >= fromStage.order) { + throw new Error( + `Rework target must come before the source (${toStage.name} is not before ${fromStage.name})` + ); + } + + return prisma.pipelineStageRework.create({ + data: { fromStageId, toStageId }, + }); +} + +export async function removeReworkPath( + pipelineId: string, + fromStageId: string, + toStageId: string, + orgId: string +) { + const pipeline = await prisma.pipelineTemplate.findFirst({ + where: { id: pipelineId, organizationId: orgId }, + select: { id: true }, + }); + if (!pipeline) throw new Error("Pipeline template not found"); + + return prisma.pipelineStageRework.delete({ + where: { + fromStageId_toStageId: { fromStageId, toStageId }, + }, + }); +} + // ─── Validation ───────────────────────────────────────── export interface PipelineValidationResult {