diff --git a/README.md b/README.md index 6ddca9a..a549ad2 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [updateWorkflowNode()](#updateworkflownode) - [deleteWorkflowNode()](#deleteworkflownode) - [addWorkflowBranch()](#addworkflowbranch) +- [rerouteWorkflowNode()](#rerouteworkflownode) - [deleteWorkflowNodesRecursive()](#deleteworkflownodesrecursive) - [createUpload()](#createupload) - [completeUpload()](#completeupload) @@ -1701,7 +1702,7 @@ const resp = await loops.getEmailMessage("clm9x3o5q002yl70a8b3c4d5e"); "fromEmail": "hello", "replyToEmail": "", "emailFormat": "styled", - "lmx": "...", + "lmx": "

...

...", "contentRevisionId": "clv8g2x4z012yl70n5o6p7q8r", "updatedAt": "2025-01-01T00:00:00.000Z" } @@ -1745,7 +1746,7 @@ const resp = await loops.updateEmailMessage("clm9x3o5q002yl70a8b3c4d5e", { previewText: "Preview text", fromName: "Loops", fromEmail: "hello", - lmx: "...", + lmx: "

...

...", }); ``` @@ -1761,7 +1762,7 @@ const resp = await loops.updateEmailMessage("clm9x3o5q002yl70a8b3c4d5e", { "fromEmail": "hello", "replyToEmail": "", "emailFormat": "styled", - "lmx": "...", + "lmx": "

...

...", "contentRevisionId": "clv8g2x4z013yl70s9t0u1v2w", "updatedAt": "2025-01-02T00:00:00.000Z", "warnings": [ @@ -2650,7 +2651,15 @@ const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g", "mailingListId": "clm1a2b3c004yl70d5e6f7g8h", "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", "queuedContactCount": 0, - "queuedContactLimitReached": false + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": "clm1a2b3c004yl70d5e6f7g8h", + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": {} + } } ``` @@ -2658,7 +2667,7 @@ const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g", ### createWorkflowNode() -Create a new default workflow node. Use `insertMode: "between"` or `insertMode: "before"`, then configure with [`updateWorkflowNode()`](#updateworkflownode). +Create a new default workflow node. Use `insertMode: "between"`, `insertMode: "before"` (with `toNodeId`), or `insertMode: "after"`, then configure with [`updateWorkflowNode()`](#updateworkflownode). [API Reference](https://loops.so/docs/api-reference/create-workflow-node) #### Parameters @@ -2667,11 +2676,11 @@ Create a new default workflow node. Use `insertMode: "between"` or `insertMode: | -------------------- | ------ | -------- | --------------------------------------------------------------------- | | `workflowId` | string | Yes | The ID of the workflow. | | `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | -| `insertMode` | string | Yes | `"between"` or `"before"`. | +| `insertMode` | string | Yes | `"between"`, `"before"`, or `"after"`. | | `nodeTypeName` | string | Yes | Node type to create (for example `TimerAction`, `AudienceFilter`). | -| `fromNodeId` | string | Cond. | Required when `insertMode` is `"between"`. | -| `toNodeId` | string | Cond. | Required when `insertMode` is `"between"`. | -| `beforeNodeId` | string | Cond. | Required when `insertMode` is `"before"`. | +| `fromNodeId` | string | Cond. | Required when `insertMode` is `"between"` or `"after"`. For `"after"`, the source must have exactly one outgoing node. | +| `toNodeId` | string | Cond. | Required when `insertMode` is `"between"` or `"before"`. | +| `beforeNodeId` | string | Cond. | Deprecated. Use `toNodeId` with `insertMode: "before"`. | #### Example @@ -2808,7 +2817,16 @@ const resp = await loops.updateWorkflowNode( ], "amount": 1, "unit": "h", - "workflowRevisionId": "clrev0w0r1k2f3l4o5w6" + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": {} + } } ``` @@ -2849,7 +2867,15 @@ const resp = await loops.deleteWorkflowNode( ], "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", "queuedContactCount": 0, - "queuedContactLimitReached": false + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": {} + } } ``` @@ -2917,6 +2943,58 @@ const resp = await loops.addWorkflowBranch( --- +### rerouteWorkflowNode() + +Reroute a source node's single outgoing connection to another valid target node. The source must have exactly one outgoing connection (branch/experiment nodes are not supported). +[API Reference](https://loops.so/docs/api-reference/reroute-node-connection) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `nodeId` | string | Yes | The ID of the source workflow node. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `newTargetNodeId` | string | Yes | The node that should receive the connection. | + +#### Example + +```javascript +const resp = await loops.rerouteWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "clt6e0v2x010yl70h1i2j3k4l", + { + expectedRevisionId: "rev_1", + newTargetNodeId: "clu7f1w3y011yl70m5n6o7p8q", + } +); +``` + +#### Response + +```json +{ + "id": "cln8p0q2r4s6t8u0v2w4x6z8", + "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1", + "typeName": "SignupTrigger", + "nextNodeIds": [ + "clu7f1w3y011yl70m5n6o7p8q" + ], + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "cln8p0q2r4s6t8u0v2w4x6z8", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": {} + } +} +``` + +--- + ### deleteWorkflowNodesRecursive() Delete a node and its downstream subtree. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`. @@ -2953,7 +3031,15 @@ const resp = await loops.deleteWorkflowNodesRecursive( ], "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", "queuedContactCount": 0, - "queuedContactLimitReached": false + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": {} + } } ``` @@ -3023,6 +3109,12 @@ const resp = await loops.completeUpload("clu7f1w3y011yl70m5n6o7p8q"); ## Version history +- `v7.2.0` (Aug 7, 2026) + - Aligned with OpenAPI 1.21.6. + - Added [`rerouteWorkflowNode()`](#rerouteworkflownode). + - Added `insertMode: "after"` for [`createWorkflowNode()`](#createworkflownode); `before` mode now prefers `toNodeId` (`beforeNodeId` remains as a deprecated alias). + - Mutation responses for mailing-list changes, node updates, and node deletes now include the latest `workflow`. + - Removed `queuedContactLimitReached` from workflow mutation responses. - `v7.1.0` (Aug 3, 2026) - Added event patterns ([`listEventPatterns()`](#listeventpatterns), [`getEventPattern()`](#geteventpattern), [`getEventPatternByName()`](#geteventpatternbyname)). - Expanded workflows with create/update/mutate APIs ([`createWorkflow()`](#createworkflow), [`updateWorkflow()`](#updateworkflow), [`changeWorkflowMailingList()`](#changeworkflowmailinglist), [`createWorkflowNode()`](#createworkflownode), [`updateWorkflowNode()`](#updateworkflownode), [`deleteWorkflowNode()`](#deleteworkflownode), [`addWorkflowBranch()`](#addworkflowbranch), [`deleteWorkflowNodesRecursive()`](#deleteworkflownodesrecursive)). diff --git a/package-lock.json b/package-lock.json index 5373ee6..12eadec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loops", - "version": "7.1.0", + "version": "7.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loops", - "version": "7.1.0", + "version": "7.2.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12", diff --git a/package.json b/package.json index 390cae5..1adaa1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loops", - "version": "7.1.0", + "version": "7.2.0", "author": "Dan Rowden ", "license": "MIT", "main": "./dist/index.cjs", diff --git a/src/__tests__/LoopsClient.test.ts b/src/__tests__/LoopsClient.test.ts index c4feba0..dbaf141 100644 --- a/src/__tests__/LoopsClient.test.ts +++ b/src/__tests__/LoopsClient.test.ts @@ -1890,7 +1890,14 @@ describe("LoopsClient", () => { mailingListId: "clm1", workflowRevisionId: "rev_2", queuedContactCount: 0, - queuedContactLimitReached: false, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: "clm1", + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, }; global.fetch = jest.fn().mockResolvedValue({ @@ -1975,6 +1982,108 @@ describe("LoopsClient", () => { }) ); }); + + it("should create a workflow node after a node", async () => { + const mockResponse = { + node: { + id: "new_node", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "TimerAction", + nextNodeIds: ["next"], + workflowRevisionId: "rev_2", + }, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "from", + nodes: {}, + workflowRevisionId: "rev_2", + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + { + expectedRevisionId: "rev_1", + insertMode: "after", + nodeTypeName: "TimerAction", + fromNodeId: "from", + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + insertMode: "after", + nodeTypeName: "TimerAction", + fromNodeId: "from", + }), + }) + ); + }); + + it("should create a workflow node before a node using toNodeId", async () => { + const mockResponse = { + node: { + id: "new_node", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "AudienceFilter", + nextNodeIds: ["to"], + workflowRevisionId: "rev_2", + }, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + { + expectedRevisionId: "rev_1", + insertMode: "before", + nodeTypeName: "AudienceFilter", + toNodeId: "to", + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + insertMode: "before", + nodeTypeName: "AudienceFilter", + toNodeId: "to", + }), + }) + ); + }); }); describe("updateWorkflowNode", () => { @@ -1987,6 +2096,14 @@ describe("LoopsClient", () => { amount: 1, unit: "h", workflowRevisionId: "rev_2", + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, }; global.fetch = jest.fn().mockResolvedValue({ @@ -2026,7 +2143,14 @@ describe("LoopsClient", () => { nodeIds: ["node1"], workflowRevisionId: "rev_2", queuedContactCount: 0, - queuedContactLimitReached: false, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, }; global.fetch = jest.fn().mockResolvedValue({ @@ -2097,6 +2221,54 @@ describe("LoopsClient", () => { }); }); + describe("rerouteWorkflowNode", () => { + it("should reroute a workflow node connection", async () => { + const mockResponse = { + id: "node1", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "SignupTrigger", + nextNodeIds: ["target"], + workflowRevisionId: "rev_2", + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "node1", + nodes: {}, + workflowRevisionId: "rev_2", + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.rerouteWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "node1", + { + expectedRevisionId: "rev_1", + newTargetNodeId: "target", + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1/reroute" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + newTargetNodeId: "target", + }), + }) + ); + }); + }); + describe("deleteWorkflowNodesRecursive", () => { it("should recursively delete workflow nodes", async () => { const mockResponse = { @@ -2104,7 +2276,14 @@ describe("LoopsClient", () => { nodeIds: ["node1", "node2"], workflowRevisionId: "rev_2", queuedContactCount: 0, - queuedContactLimitReached: false, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, }; global.fetch = jest.fn().mockResolvedValue({ diff --git a/src/index.ts b/src/index.ts index b4355a4..6144106 100644 --- a/src/index.ts +++ b/src/index.ts @@ -459,7 +459,20 @@ type CreateWorkflowNodeParams = expectedRevisionId: WorkflowExpectedRevisionId; insertMode: "before"; nodeTypeName: CreateWorkflowNodeTypeName; + toNodeId: string; + } + | { + expectedRevisionId: WorkflowExpectedRevisionId; + insertMode: "before"; + nodeTypeName: CreateWorkflowNodeTypeName; + /** @deprecated Use `toNodeId` instead. */ beforeNodeId: string; + } + | { + expectedRevisionId: WorkflowExpectedRevisionId; + insertMode: "after"; + nodeTypeName: CreateWorkflowNodeTypeName; + fromNodeId: string; }; type CreatedWorkflowNode = WorkflowNodeWithRevision & { @@ -486,7 +499,6 @@ interface WorkflowMailingListPreview { status: "dryRun" | "queuedContactsFound"; mailingListId: string | null; queuedContactCount: number; - queuedContactLimitReached: boolean; } interface WorkflowMailingListUpdatedResponse { @@ -494,7 +506,7 @@ interface WorkflowMailingListUpdatedResponse { mailingListId: string | null; workflowRevisionId: string; queuedContactCount: number; - queuedContactLimitReached: boolean; + workflow: SimplifiedWorkflow; } type ChangeWorkflowMailingListResponse = @@ -505,7 +517,6 @@ interface WorkflowQueuedContactDeletePreview { status: "dryRun" | "queuedContactsFound"; nodeIds: string[]; queuedContactCount: number; - queuedContactLimitReached: boolean; } interface WorkflowDeletedResponse { @@ -513,9 +524,17 @@ interface WorkflowDeletedResponse { nodeIds: string[]; workflowRevisionId: string; queuedContactCount: number; - queuedContactLimitReached: boolean; + workflow: SimplifiedWorkflow; } +type UpdateWorkflowNodeResponse = WorkflowNodeWithRevision & { + workflow: SimplifiedWorkflow; +}; + +type RerouteNodeConnectionResponse = WorkflowNodeWithRevision & { + workflow: SimplifiedWorkflow; +}; + type DeleteWorkflowNodeResponse = | WorkflowQueuedContactDeletePreview | WorkflowDeletedResponse; @@ -2352,8 +2371,9 @@ class LoopsClient { * Create a new default workflow node and return it with the latest workflow. * * Use `insertMode: "between"` to place the node between an existing connection, - * or `insertMode: "before"` to insert before a node. Configure the node after - * creation with `updateWorkflowNode()`. + * `insertMode: "before"` to insert before a node (`toNodeId`), or + * `insertMode: "after"` to insert after a node that has exactly one outgoing + * connection. Configure the node after creation with `updateWorkflowNode()`. * * @param {string} workflowId The ID of the workflow. * @param {Object} params Create parameters including `insertMode` and revision. @@ -2406,7 +2426,7 @@ class LoopsClient { * * @see https://loops.so/docs/api-reference/update-workflow-node * - * @returns {Object} Updated workflow node (JSON) + * @returns {Object} Updated workflow node and latest workflow (JSON) */ async updateWorkflowNode( workflowId: string, @@ -2418,7 +2438,7 @@ class LoopsClient { expectedRevisionId: WorkflowExpectedRevisionId; payload: UpdateWorkflowNodePayload; } - ): Promise { + ): Promise { return this._makeQuery({ path: `v1/workflows/${workflowId}/nodes/${nodeId}`, method: "POST", @@ -2501,6 +2521,40 @@ class LoopsClient { }); } + /** + * Reroute a source node's single outgoing connection to another target node. + * + * The source node must have exactly one outgoing connection. Branch and + * experiment branch nodes cannot be rerouted with this method. + * + * @param {string} workflowId The ID of the workflow. + * @param {string} nodeId The ID of the source workflow node. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {string} params.newTargetNodeId The node that should receive the connection. + * + * @see https://loops.so/docs/api-reference/reroute-node-connection + * + * @returns {Object} Updated source node and latest workflow (JSON) + */ + async rerouteWorkflowNode( + workflowId: string, + nodeId: string, + { + expectedRevisionId, + newTargetNodeId, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + newTargetNodeId: string; + } + ): Promise { + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes/${nodeId}/reroute`, + method: "POST", + payload: { expectedRevisionId, newTargetNodeId }, + }); + } + /** * Delete a node and its downstream subtree. * @@ -2875,6 +2929,8 @@ export { WorkflowQueuedContactDeletePreview, WorkflowDeletedResponse, DeleteWorkflowNodeResponse, + UpdateWorkflowNodeResponse, + RerouteNodeConnectionResponse, WorkflowContactPropertyComparisonOperator, WorkflowContactPropertyComparison, WorkflowContactPropertyQuery,