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.
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
- 01
process()
Executor calls process(context, uid, props, args). Return NodeResult.single(...) or another NodeResultType.
- 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).
- 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
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}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]).
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.
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.
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.
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.
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)