Demo

Workflow

Build custom workflow nodes (IWorkflowNode), register them on the module, trigger workflows from services, and expose controller APIs as discoverable @WorkflowEntry steps.

Overview

The workflow engine runs graphs of nodes. Plugins contribute nodes and can start workflows when domain events happen. Import workflow types from @quan-erp/shared-backend-core.

  • Custom node — implement IWorkflowNode, register under @Module({ workflowNodes })
  • Trigger — isTriggerable: true + WorkflowService.findByInitId / executeWorkflow from controllers/services
  • API entry — @WorkflowEntry on a controller method so the editor can call that HTTP API as a step
  • Frontend — register matching node UI via workflowNodes() from @quan-erp/base-frontend (see Frontend docs)

Node data model

  • Props — configuration from the workflow editor UI
  • Args — values injected when the node runs (previous output or executeWorkflow args)
  • ReturnType — value this node produces for downstream nodes
  • type — always `${metadata.name}/${NODE_ID}` for plugin nodes

Props & return schemas

Declare JSON schemas with PropsType and infer TypeScript types via SchemaToType. Schemas drive both process() typing and the frontend editor.

TSXschemas
1import { 2 PropsType, 3 SchemaToType, 4} from "@quan-erp/shared-backend-core"; 5 6const inputSchema = { 7 type: "number", 8} as const satisfies PropsType; 9type Props = SchemaToType<typeof inputSchema>; 10 11const returnSchema = { 12 type: "object", 13 properties: { 14 bookingId: { type: "number" }, 15 bookingSource: { type: "string" }, 16 }, 17} as const satisfies PropsType; 18type ReturnType = SchemaToType<typeof returnSchema>; 19 20export type Args = { 21 bookingId: number; 22 bookingSource: string; 23};

Execution lifecycle

  1. 01

    process()

    Executor calls process(context, uid, props, args). Return NodeResult.single(...) or another NodeResultType.

  2. 02

    nextToExecute()

    After process, executor asks for the next node ids. Return null to follow the UI next edges; return string[] to override routing (if / loop nodes).

  3. 03

    Routing

    null → use the node’s next array from the canvas. string[] → run those node ids instead.

ExecutionContext

Each run gets an ExecutionContext shared by all nodes. Resolve UI-mapped props before use; optionally sub-execute another node.

  • context.pipeData.resolve(props) — expand ${{node_….output}} expressions; always resolve before reading props
  • context.execute(nodeId, args) — run another node immediately and await its NodeResultType (T[]) (e.g. AI agent → tool)
  • toolResult[0] — raw payload from a sub-execution (NodeResultType is an array)
  • context.findWorkflowNodeById(uid) — inspect current node edges for custom nextToExecute
TSXresolve props
1import type { 2 ExecutionContext, 3 NodeResultType, 4 PromisableReturn, 5} from "@quan-erp/shared-backend-core"; 6import { NodeResult } from "@quan-erp/shared-backend-core"; 7 8function process( 9 context: ExecutionContext, 10 uid: string, 11 props: { expression: string }, 12 args: unknown, 13): PromisableReturn<NodeResultType<boolean>> { 14 const resolvedProps = context.pipeData.resolve(props); 15 const evaluateResult = eval(resolvedProps.expression); 16 return NodeResult.single(evaluateResult as boolean); 17}
TSXsub-execute
1import type { ExecutionContext } from "@quan-erp/shared-backend-core"; 2 3async function callTool( 4 context: ExecutionContext, 5 toolNodeId: string, 6 toolArgs: Record<string, unknown>, 7) { 8 const toolResult = await context.execute(toolNodeId, toolArgs); 9 return toolResult[0]; 10}

Custom routing (nextToExecute)

Override the default edge walk for conditionals and loops. Return null to keep canvas edges. nextToExecute receives the NodeResultType array from process (use args[0]).

TSXif-style routing
1import type { 2 ExecutionContext, 3 NodeResultType, 4 PromisableReturn, 5} from "@quan-erp/shared-backend-core"; 6 7function nextToExecute( 8 context: ExecutionContext, 9 uid: string, 10 args: NodeResultType<boolean>, 11): PromisableReturn<string[]> | PromisableReturn<null> { 12 const current = context.findWorkflowNodeById(uid); 13 const ifTrue = current?.next?.[0]; 14 const ifFalse = current?.next?.[1]; 15 if (args[0] && ifTrue?.id) return [ifTrue.id]; 16 if (!args[0] && ifFalse?.id) return [ifFalse.id]; 17 return null; 18}

Full trigger node

Trigger nodes set isTriggerable: true and usually pass Args through as the return value so the rest of the graph can use them.

TSXOnBookingWorkflowNode
1import { 2 ExecutionContext, 3 IWorkflowNode, 4 NodeResult, 5 NodeResultType, 6 PromisableReturn, 7 PropsType, 8 SchemaToType, 9 WorkflowNodeDefination, 10} from "@quan-erp/shared-backend-core"; 11import metadata from "../../../module.metadata.json" with { type: "json" }; 12 13const inputSchema = { 14 type: "number", 15} as const satisfies PropsType; 16type Props = SchemaToType<typeof inputSchema>; 17 18const returnSchema = { 19 type: "object", 20 properties: { 21 bookingId: { type: "number" }, 22 bookingSource: { type: "string" }, 23 }, 24} as const satisfies PropsType; 25type ReturnType = SchemaToType<typeof returnSchema>; 26 27export type Args = { 28 bookingId: number; 29 bookingSource: string; 30}; 31 32export class OnBookingWorkflowNode 33 implements IWorkflowNode<Props, ReturnType, Args> 34{ 35 static NODE_ID = "bookings"; 36 37 process( 38 context: ExecutionContext, 39 id: string, 40 props: Props, 41 args: Args, 42 ): PromisableReturn<NodeResultType<ReturnType>> { 43 return NodeResult.single(args); 44 } 45 46 nextToExecute( 47 context: ExecutionContext, 48 uid: string, 49 args: NodeResultType<ReturnType>, 50 ): PromisableReturn<string[]> | PromisableReturn<null> { 51 return null; 52 } 53 54 getDefination(): WorkflowNodeDefination { 55 return { 56 type: `${metadata.name}/${OnBookingWorkflowNode.NODE_ID}`, 57 group: ["any"], 58 displayName: "On Hotel Booked", 59 description: "Triggers when a hotel is booked", 60 props: inputSchema, 61 isTriggerable: true, 62 returnType: { 63 type: "array", 64 items: returnSchema, 65 }, 66 }; 67 } 68}

Register on the module

Add the node class to workflowNodes so the backend mounts it and exposes the definition to the editor.

TSXmodule
1import { Module } from "@quan-erp/shared-backend-core"; 2import metadata from "../../module.metadata.json" with { type: "json" }; 3import { OnBookingWorkflowNode } from "./workflow/on-booking.workflow.js"; 4 5@Module({ 6 name: metadata.name, 7 providers: [], 8 controllers: [], 9 entities: [], 10 workflowNodes: [OnBookingWorkflowNode], 11}) 12export class MyPluginModule {}

Trigger workflows programmatically

For isTriggerable nodes, inject WorkflowService from the builtin plugin, find active workflows whose init id is `${metadata.name}/${NODE_ID}`, and execute each with Args.

TSXexecuteWorkflow
1import { 2 ContainerRegistryManager, 3 Controller, 4 Inject, 5 Post, 6 ResponseDto, 7 WorkflowService, 8} from "@quan-erp/shared-backend-core"; 9import metadata from "../../module.metadata.json" with { type: "json" }; 10import { OnBookingWorkflowNode } from "../workflow/on-booking.workflow.js"; 11 12@Controller("/bookings") 13export class BookingController { 14 @Inject(WorkflowService, ContainerRegistryManager.BUILTIN_PLUGIN) 15 workflowService: WorkflowService; 16 17 @Post("/") 18 async createBooking() { 19 // … create booking … 20 const workflows = await this.workflowService.findByInitId( 21 `${metadata.name}/${OnBookingWorkflowNode.NODE_ID}`, 22 { active: true }, 23 ); 24 25 await Promise.all( 26 workflows.map((w) => 27 this.workflowService.executeWorkflow({ 28 workflow: w, 29 args: { 30 bookingId: 1, 31 bookingSource: "web", 32 }, 33 }), 34 ), 35 ); 36 37 return ResponseDto.okWithEmpty(); 38 } 39}

@WorkflowEntry (HTTP as a step)

Mark a controller method so the workflow engine can discover and invoke that API as a node. Provide name, description, and a returnType JSON schema matching the response shape.

TSX@WorkflowEntry
1import { 2 Controller, 3 Get, 4 WorkflowEntry, 5} from "@quan-erp/shared-backend-core"; 6 7@Controller("/branch") 8export class BranchController { 9 @Get("/") 10 @WorkflowEntry({ 11 name: "get-branch", 12 description: "get all branches", 13 returnType: { 14 type: "object", 15 properties: { 16 payload: { 17 type: "array", 18 items: { 19 type: "object", 20 properties: { 21 id: { type: "number" }, 22 name: { type: "string" }, 23 }, 24 }, 25 }, 26 }, 27 }, 28 }) 29 async list() { 30 /* … */ 31 } 32}

Checklist

  • Schemas: PropsType + SchemaToType for props and returnType
  • Implement process, nextToExecute, getDefination on IWorkflowNode
  • type: `${metadata.name}/${NODE_ID}` — never omit the plugin prefix
  • Register in @Module({ workflowNodes })
  • Triggerable nodes: fire via WorkflowService on the domain event
  • Always context.pipeData.resolve(props) before using editor props
  • Return values with NodeResult.single(...) (or the matching NodeResult helper)