import type { ActionNodeData, ApprovalNodeData, DesignerEdgeData, DesignerNodeData, ProcessDefinitionDocument, StepDocument, } from '@/types/definition' import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition' import { formatWhen } from '@/mapper/when' import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel' export const APPROVAL_TYPE = 'approval' export const ACTION_TYPE = 'action' export const PARALLEL_TYPE = 'parallel' export const END_TYPE = 'end' export const END_NODE_ID = '__end__' export interface DesignerNode { id: string type: string position: { x: number; y: number } data: DesignerNodeData parentNode?: string extent?: 'parent' style?: { width: string; height: string } zIndex?: number sourceHandle?: string targetHandle?: string } export interface DesignerEdge { id: string source: string target: string sourceHandle?: string targetHandle?: string label?: string data: DesignerEdgeData } export function edgeLabel(data: DesignerEdgeData | undefined): string { const when = formatWhen(data?.when) return when || 'default' } export function toDocument( id: string, name: string, startStepId: string, nodes: DesignerNode[], edges: DesignerEdge[], ): ProcessDefinitionDocument { const top = nodes.filter((node) => node.type !== END_TYPE && !node.parentNode) const start = top.find((node) => node.id === startStepId) ?? top[0] const rest = top.filter((node) => node.id !== start?.id) const ordered = start ? [start, ...rest] : rest const steps: StepDocument[] = ordered.map((node) => { const data = node.data if (data?.kind === 'ACTION') { return { id: node.id, name: data.name, kind: 'ACTION', action: data.action } } if (data?.kind === 'PARALLEL') { return { id: node.id, name: data.name, kind: 'PARALLEL', branches: branchesOf(node.id, data.lanes, nodes, edges), } } return approvalStepDocument(data as ApprovalNodeData, node.id) }) const transitions = edges .filter((edge) => { const source = nodes.find((node) => node.id === edge.source) return source && !source.parentNode }) .map((edge) => { const target = nodes.find((node) => node.id === edge.target) const data = edge.data ?? { priority: 0 } return { from: edge.source, to: target?.type === END_TYPE || target?.parentNode ? null : edge.target, ...(data.when ? { when: data.when } : {}), priority: data.priority ?? 0, } }) return { id, name, startStep: start?.id, steps, transitions, } } export function fromDocument( doc: ProcessDefinitionDocument, layout: Record = {}, ): { nodes: DesignerNode[]; edges: DesignerEdge[]; startStepId: string } { const nodes: DesignerNode[] = [] const innerEdges: DesignerEdge[] = [] doc.steps.forEach((step, index) => { const position = layout[step.id] ?? { x: 220, y: 40 + index * 140 } if (step.kind === 'PARALLEL') { const built = materializeParallel(step.id, step.name, position, step.branches ?? []) nodes.push(built.parent, ...built.children) innerEdges.push(...built.edges) return } if (step.kind === 'ACTION') { const data: ActionNodeData = { ...defaultActionData(), name: step.name, action: step.action ?? '', isStart: false, } nodes.push({ id: step.id, type: ACTION_TYPE, position, data }) return } const data: ApprovalNodeData = { ...defaultApprovalData(), name: step.name, candidates: step.candidates ?? [], policy: step.policy ?? 'ANY', dueEnabled: Boolean(step.due), due: step.due ? { after: step.due.after, then: step.due.then, to: step.due.to ?? undefined, action: step.due.action ?? undefined, } : { after: 'PT48H', then: 'notify' }, isStart: false, } nodes.push({ id: step.id, type: APPROVAL_TYPE, position, data }) }) const hasEnd = doc.transitions.some((t) => t.to == null) if (hasEnd) { nodes.push({ id: END_NODE_ID, type: END_TYPE, position: layout[END_NODE_ID] ?? { x: 220, y: 40 + doc.steps.length * 140 }, data: defaultEndData(), }) } const startStepId = doc.startStep ?? doc.steps[0]?.id ?? '' for (const node of nodes) { if (node.data && node.data.kind !== 'END') { node.data.isStart = node.id === startStepId } } const edges: DesignerEdge[] = [ ...innerEdges, ...doc.transitions.map((t, i) => { const data: DesignerEdgeData = { when: t.when, priority: t.priority ?? 0, } return { id: `e-${i}-${t.from}-${t.to ?? 'end'}`, source: t.from, target: t.to ?? END_NODE_ID, label: edgeLabel(data), data, } }), ] return { nodes, edges, startStepId } } export function layoutFromNodes(nodes: DesignerNode[]): Record { const layout: Record = {} for (const node of nodes) { layout[node.id] = { x: node.position.x, y: node.position.y } } return layout } export interface GraphError { message: string nodeId?: string edgeId?: string } export function comparableDocument(doc: ProcessDefinitionDocument): string { return JSON.stringify( { id: doc.id, name: doc.name, startStep: doc.startStep, steps: doc.steps, transitions: doc.transitions, }, null, 2, ) } export function validateGraph( id: string, name: string, startStepId: string, nodes: DesignerNode[], edges: DesignerEdge[], ): GraphError[] { const errors: GraphError[] = [] if (!id.trim()) { errors.push({ message: 'Process id is required' }) } if (!name.trim()) { errors.push({ message: 'Process name is required' }) } const stepNodes = nodes.filter((n) => n.type !== END_TYPE) if (stepNodes.length === 0) { errors.push({ message: 'At least one step is required' }) } const ids = new Set() for (const node of stepNodes) { if (ids.has(node.id)) { errors.push({ message: `Duplicate step id: ${node.id}`, nodeId: node.id }) } ids.add(node.id) const data = node.data if (data?.kind === 'APPROVAL' && data.candidates.length === 0) { errors.push({ message: `Approval step ${node.id} needs candidates`, nodeId: node.id }) } if (data?.kind === 'ACTION' && !data.action.trim()) { errors.push({ message: `ACTION step ${node.id} needs action`, nodeId: node.id }) } if (data?.kind === 'PARALLEL') { if (data.lanes.length < 2) { errors.push({ message: `PARALLEL ${node.id} needs at least two branches`, nodeId: node.id }) } const seenLanes = new Set() for (const lane of data.lanes) { if (!lane.trim() || seenLanes.has(lane)) { errors.push({ message: `PARALLEL ${node.id} has a bad branch id: ${lane}`, nodeId: node.id }) } seenLanes.add(lane) if (!nodes.some((child) => child.parentNode === node.id && laneOf(child.data) === lane)) { errors.push({ message: `Branch ${lane} needs a step`, nodeId: node.id }) } } } const outgoing = edges.filter((e) => e.source === node.id) if (!node.parentNode && outgoing.length === 0) { errors.push({ message: `Step ${node.id} has no outgoing edge`, nodeId: node.id }) } } if (startStepId && !ids.has(startStepId)) { errors.push({ message: `Unknown startStep: ${startStepId}` }) } if (nodes.some((node) => node.id === startStepId && node.parentNode)) { errors.push({ message: 'startStep must not be inside a parallel branch' }) } const seenPriority = new Set() for (const edge of edges) { const source = nodes.find((n) => n.id === edge.source) const target = nodes.find((n) => n.id === edge.target) if (!target) { errors.push({ message: `Edge target missing: ${edge.target}`, edgeId: edge.id }) } else if (target.type !== END_TYPE && !ids.has(edge.target)) { errors.push({ message: `Edge target is not a step: ${edge.target}`, edgeId: edge.id }) } if (!source || !ids.has(edge.source)) { errors.push({ message: `Edge source is not a step: ${edge.source}`, edgeId: edge.id }) } if (source?.parentNode || target?.parentNode) { if (source?.parentNode !== target?.parentNode || laneOf(source?.data) !== laneOf(target?.data)) { errors.push({ message: `Branch edge must stay in the same branch`, edgeId: edge.id }) } } const key = `${edge.source}:${edge.data?.priority ?? 0}` if (seenPriority.has(key)) { errors.push({ message: `Duplicate outgoing priority: ${edge.source} / ${edge.data?.priority ?? 0}`, edgeId: edge.id, }) } seenPriority.add(key) } return errors }