Pipeline editor: rework paths (pass/fail loops) now configurable in the browser
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
This commit is contained in:
parent
9ff0f03a4d
commit
f89fb73aff
6 changed files with 392 additions and 22 deletions
|
|
@ -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<PipelineGraphMode>("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) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Dependency graph */}
|
||||
{/* Right: Dependency + rework graph */}
|
||||
<div className="rounded-lg border bg-[var(--card)] overflow-hidden" style={{ minHeight: 500 }}>
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="label-upper text-[10px] font-semibold tracking-wider text-[var(--muted-foreground)]">
|
||||
Dependency Graph
|
||||
Workflow Graph
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--muted)]/40 p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={graphMode === "dependency" ? "default" : "ghost"}
|
||||
className="h-6 gap-1 px-2 text-[10px]"
|
||||
onClick={() => setGraphMode("dependency")}
|
||||
title="Drag between stages to create forward dependencies"
|
||||
>
|
||||
<Workflow className="h-3 w-3" />
|
||||
Dependencies
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={graphMode === "rework" ? "default" : "ghost"}
|
||||
className="h-6 gap-1 px-2 text-[10px]"
|
||||
onClick={() => setGraphMode("rework")}
|
||||
title="Drag backward (higher → lower order) to allow pushing work back on failure"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Rework paths
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 460 }}>
|
||||
<div className="border-b bg-[var(--muted)]/20 px-3 py-1.5 text-[10px] text-[var(--muted-foreground)]">
|
||||
{graphMode === "dependency" ? (
|
||||
<>Drag from one stage to another to add a <strong>forward prerequisite</strong>. Click the × on a solid line to remove.</>
|
||||
) : (
|
||||
<>Drag from a later stage to an earlier one to add a <strong>rework (pushback) path</strong> — e.g., Final Approval → In Progress. Drawn as dashed red. Click × to remove.</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 440 }}>
|
||||
<PipelineGraph
|
||||
stages={stages}
|
||||
mode={graphMode}
|
||||
onConnect={handleConnect}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
onConnectRework={handleConnectRework}
|
||||
onDeleteRework={handleDeleteRework}
|
||||
onSelectStage={selectStage}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
67
src/app/api/pipelines/[pipelineId]/reworks/route.ts
Normal file
67
src/app/api/pipelines/[pipelineId]/reworks/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
87
src/components/pipeline-builder/rework-edge.tsx
Normal file
87
src/components/pipeline-builder/rework-edge.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<path d={edgePath} fill="none" stroke="transparent" strokeWidth={20} />
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "6 4",
|
||||
}}
|
||||
/>
|
||||
{edgeData?.onDelete && (
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type="button"
|
||||
className="nodrag nopan absolute flex items-center justify-center size-5 rounded-full bg-[var(--muted)] border shadow-sm transition-colors hover:bg-destructive hover:text-white hover:border-destructive focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: "all",
|
||||
borderColor: stroke,
|
||||
color: stroke,
|
||||
}}
|
||||
onClick={edgeData.onDelete}
|
||||
aria-label="Delete rework path"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue