diff --git a/CHANGELOG.md b/CHANGELOG.md index e006f53..2dfd39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v3.3.0 - Aug 7, 2026 + +OpenAPI 1.21.6: + +- Added `workflows->rerouteNode()` to reroute a node's outgoing connection to another target. +- `workflows->createNode()` now accepts `insert_mode: "after"`, and prefers `to_node_id` over now-deprecated `before_node_id` for `before`. +- Mutation responses for mailing-list changes, node updates, and node deletes now include the latest `workflow`. +- Removed `queuedContactLimitReached` from workflow mutation responses. + ## v3.2.0 - Aug 3, 2026 OpenAPI 1.21.1: diff --git a/README.md b/README.md index e139ba0..26cabc4 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [workflows->updateNode()](#workflows-updatenode) - [workflows->deleteNode()](#workflows-deletenode) - [workflows->addBranch()](#workflows-addbranch) +- [workflows->rerouteNode()](#workflows-reroutenode) - [workflows->deleteNodeRecursive()](#workflows-deletenoderecursive) - [eventPatterns->list()](#eventpatterns-list) - [eventPatterns->get()](#eventpatterns-get) @@ -1764,7 +1765,10 @@ $result = $loops->workflows->getNode( Create a new default workflow node. -Use `insert_mode: "between"` with `from_node_id` and `to_node_id`, or `insert_mode: "before"` with `before_node_id`. +Use `$insert_mode` to choose placement: +- `between` — insert between `$from_node_id` and `$to_node_id` +- `before` — insert before `$to_node_id` +- `after` — insert after `$from_node_id` (source must have exactly one outgoing connection) [API Reference](https://loops.so/docs/api-reference/create-workflow-node) @@ -1774,11 +1778,11 @@ Use `insert_mode: "between"` with `from_node_id` and `to_node_id`, or `insert_mo | ------------------------- | ------ | -------- | --------------------------------------------------------------------- | | `$workflow_id` | string | Yes | The ID of the workflow. | | `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. | -| `$insert_mode` | string | Yes | `between` or `before`. | +| `$insert_mode` | string | Yes | `between`, `before`, or `after`. | | `$node_type_name` | string | Yes | One of `AudienceFilter`, `BranchNode`, `ExperimentBranchNode`, `TimerAction`, `SendEmailAction`, `VariantNode`. | -| `$from_node_id` | string | No | Required when `insert_mode` is `between`. | -| `$to_node_id` | string | No | Required when `insert_mode` is `between`. | -| `$before_node_id` | string | No | Required when `insert_mode` is `before`. | +| `$from_node_id` | string | Cond. | Required for `between` and `after`. Not permitted for `before`. | +| `$to_node_id` | string | Cond. | Required for `between`. For `before`, provide this or `$before_node_id` (not both). Not permitted for `after`. | +| `$before_node_id` | string | Cond. | Deprecated. For `before`, provide this or `$to_node_id` (not both). | #### Example @@ -1791,6 +1795,22 @@ $result = $loops->workflows->createNode( from_node_id: 'clt0u3v5w0232sy31kqvbzs34', to_node_id: 'clt0u3v5w0232sy31kqvbzs35' ); + +$result = $loops->workflows->createNode( + workflow_id: 'cls9t2u4v0210rx20jpuary23', + expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8', + insert_mode: 'after', + node_type_name: 'TimerAction', + from_node_id: 'clt0u3v5w0232sy31kqvbzs34' +); + +$result = $loops->workflows->createNode( + workflow_id: 'cls9t2u4v0210rx20jpuary23', + expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8', + insert_mode: 'before', + node_type_name: 'TimerAction', + to_node_id: 'clt0u3v5w0232sy31kqvbzs35' +); ``` --- @@ -1880,6 +1900,34 @@ $result = $loops->workflows->addBranch( --- +### workflows->rerouteNode() + +Reroute a source node's outgoing connection to another valid target node. The source node must have exactly one outgoing connection. Branch and experiment branch nodes cannot be rerouted with this endpoint. + +[API Reference](https://loops.so/docs/api-reference/reroute-node-connection) + +#### Parameters + +| Name | Type | Required | Notes | +| ------------------------- | ------ | -------- | --------------------------------------------------------------------- | +| `$workflow_id` | string | Yes | The ID of the workflow. | +| `$node_id` | string | Yes | The ID of the source workflow node. | +| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. | +| `$new_target_node_id` | string | Yes | The node that should receive the connection. | + +#### Example + +```php +$result = $loops->workflows->rerouteNode( + workflow_id: 'cls9t2u4v0210rx20jpuary23', + node_id: 'clt0u3v5w0232sy31kqvbzs34', + expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8', + new_target_node_id: 'cln3c5d7e9f1g3h5i7j9k1l3' +); +``` + +--- + ### workflows->deleteNodeRecursive() Delete a node and its downstream subtree. diff --git a/src/Workflows.php b/src/Workflows.php index b26a97c..143dc41 100644 --- a/src/Workflows.php +++ b/src/Workflows.php @@ -105,12 +105,42 @@ public function createNode( ]; if ($insert_mode === 'between') { + if ($from_node_id === null || $to_node_id === null) { + throw new \InvalidArgumentException(message: 'from_node_id and to_node_id are required when insert_mode is "between".'); + } + if ($before_node_id !== null) { + throw new \InvalidArgumentException(message: 'before_node_id is not permitted when insert_mode is "between".'); + } $payload['fromNodeId'] = $from_node_id; $payload['toNodeId'] = $to_node_id; } elseif ($insert_mode === 'before') { - $payload['beforeNodeId'] = $before_node_id; + if ($from_node_id !== null) { + throw new \InvalidArgumentException(message: 'from_node_id is not permitted when insert_mode is "before".'); + } + if ($to_node_id !== null && $before_node_id !== null) { + throw new \InvalidArgumentException(message: 'Provide either to_node_id or before_node_id when insert_mode is "before", not both.'); + } + if ($to_node_id === null && $before_node_id === null) { + throw new \InvalidArgumentException(message: 'to_node_id or before_node_id is required when insert_mode is "before".'); + } + if ($to_node_id !== null) { + $payload['toNodeId'] = $to_node_id; + } else { + $payload['beforeNodeId'] = $before_node_id; + } + } elseif ($insert_mode === 'after') { + if ($from_node_id === null) { + throw new \InvalidArgumentException(message: 'from_node_id is required when insert_mode is "after".'); + } + if ($to_node_id !== null) { + throw new \InvalidArgumentException(message: 'to_node_id is not permitted when insert_mode is "after".'); + } + if ($before_node_id !== null) { + throw new \InvalidArgumentException(message: 'before_node_id is not permitted when insert_mode is "after".'); + } + $payload['fromNodeId'] = $from_node_id; } else { - throw new \InvalidArgumentException(message: 'insert_mode must be "between" or "before".'); + throw new \InvalidArgumentException(message: 'insert_mode must be "between", "before", or "after".'); } return $this->client->query(method: 'POST', endpoint: 'v1/workflows/' . $workflow_id . '/nodes', options: [ @@ -176,6 +206,24 @@ public function addBranch( ); } + public function rerouteNode( + string $workflow_id, + string $node_id, + ?string $expected_revision_id, + string $new_target_node_id + ): mixed { + return $this->client->query( + method: 'POST', + endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id . '/reroute', + options: [ + 'json' => [ + 'expectedRevisionId' => $expected_revision_id, + 'newTargetNodeId' => $new_target_node_id, + ] + ] + ); + } + public function deleteNodeRecursive( string $workflow_id, string $node_id, diff --git a/tests/WorkflowsTest.php b/tests/WorkflowsTest.php index 2a8f24a..c0d7c8c 100644 --- a/tests/WorkflowsTest.php +++ b/tests/WorkflowsTest.php @@ -202,6 +202,80 @@ public function testCreateNodeBetween(): void $this->assertEquals('node_3', $result['node']['id']); } + public function testCreateNodeBeforeWithToNodeId(): void + { + $workflowId = 'wf_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/workflows/' . $workflowId . '/nodes', + $this->callback(function ($options) { + return $options['json'] === [ + 'expectedRevisionId' => 'rev_123', + 'insertMode' => 'before', + 'nodeTypeName' => 'TimerAction', + 'toNodeId' => 'node_2', + ]; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'node' => ['id' => 'node_3', 'typeName' => 'TimerAction'], + 'workflow' => ['id' => $workflowId], + ]) + )); + + $result = $this->client->workflows->createNode( + workflow_id: $workflowId, + expected_revision_id: 'rev_123', + insert_mode: 'before', + node_type_name: 'TimerAction', + to_node_id: 'node_2' + ); + + $this->assertEquals('node_3', $result['node']['id']); + } + + public function testCreateNodeAfter(): void + { + $workflowId = 'wf_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/workflows/' . $workflowId . '/nodes', + $this->callback(function ($options) { + return $options['json'] === [ + 'expectedRevisionId' => 'rev_123', + 'insertMode' => 'after', + 'nodeTypeName' => 'TimerAction', + 'fromNodeId' => 'node_1', + ]; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'node' => ['id' => 'node_3', 'typeName' => 'TimerAction'], + 'workflow' => ['id' => $workflowId], + ]) + )); + + $result = $this->client->workflows->createNode( + workflow_id: $workflowId, + expected_revision_id: 'rev_123', + insert_mode: 'after', + node_type_name: 'TimerAction', + from_node_id: 'node_1' + ); + + $this->assertEquals('node_3', $result['node']['id']); + } + public function testUpdateNode(): void { $workflowId = 'wf_123'; @@ -314,6 +388,43 @@ public function testAddBranch(): void $this->assertEquals('node_child', $result['node']['id']); } + public function testRerouteNode(): void + { + $workflowId = 'wf_123'; + $nodeId = 'node_source'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/workflows/' . $workflowId . '/nodes/' . $nodeId . '/reroute', + $this->callback(function ($options) { + return $options['json'] === [ + 'expectedRevisionId' => 'rev_123', + 'newTargetNodeId' => 'node_target', + ]; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $nodeId, + 'nextNodeIds' => ['node_target'], + 'workflowRevisionId' => 'rev_456', + 'workflow' => ['id' => $workflowId], + ]) + )); + + $result = $this->client->workflows->rerouteNode( + workflow_id: $workflowId, + node_id: $nodeId, + expected_revision_id: 'rev_123', + new_target_node_id: 'node_target' + ); + + $this->assertEquals(['node_target'], $result['nextNodeIds']); + } + public function testDeleteNodeRecursive(): void { $workflowId = 'wf_123'; @@ -337,7 +448,6 @@ public function testDeleteNodeRecursive(): void 'status' => 'dryRun', 'nodeIds' => [$nodeId, 'node_789'], 'queuedContactCount' => 0, - 'queuedContactLimitReached' => false, ]) ));