From b50a952eaeda7f7159f729f7fe66ea577e4f7de9 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:24:20 -0700 Subject: [PATCH] api updates --- README.md | 2 +- workflows.go | 152 ++++++++++++++++++++++++++++++++++++++++------ workflows_test.go | 143 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 275 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f4ab0ef..8f162fd 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ client := loops.NewClient("YOUR_API_KEY", - Components — `GetComponent`, `ListComponents`, `CreateComponent`, `UpdateComponent` - Themes — `GetTheme`, `ListThemes`, `CreateTheme`, `UpdateTheme` - Uploads — `Upload`, `CreateUpload`, `CompleteUpload` -- Workflows — `ListWorkflows`, `GetWorkflow`, `GetWorkflowNode`, `CreateWorkflow`, `UpdateWorkflow`, `ChangeWorkflowMailingList`, `CreateWorkflowNode`, `UpdateWorkflowNode`, `AddWorkflowBranch`, `DeleteWorkflowNode`, `DeleteWorkflowNodeRecursive` +- Workflows — `ListWorkflows`, `GetWorkflow`, `GetWorkflowNode`, `CreateWorkflow`, `UpdateWorkflow`, `ChangeWorkflowMailingList`, `CreateWorkflowNode`, `UpdateWorkflowNode`, `AddWorkflowBranch`, `RerouteNodeConnection`, `DeleteWorkflowNode`, `DeleteWorkflowNodeRecursive` Full reference: [pkg.go.dev/github.com/loops-so/loops-go](https://pkg.go.dev/github.com/loops-so/loops-go). diff --git a/workflows.go b/workflows.go index 45d92af..54ba05e 100644 --- a/workflows.go +++ b/workflows.go @@ -1255,13 +1255,14 @@ type ChangeWorkflowMailingListRequest struct { // ChangeWorkflowMailingListResponse is returned by // [Client.ChangeWorkflowMailingList]. Status is one of the -// WorkflowMutationStatus* values. WorkflowRevisionID is set only when Status -// is "updated". +// WorkflowMutationStatus* values. WorkflowRevisionID and Workflow are set only +// when Status is "updated". type ChangeWorkflowMailingListResponse struct { - Status string `json:"status"` - MailingListID *string `json:"mailingListId"` - WorkflowRevisionID *string `json:"workflowRevisionId,omitempty"` - QueuedContactCount float64 `json:"queuedContactCount"` + Status string `json:"status"` + MailingListID *string `json:"mailingListId"` + WorkflowRevisionID *string `json:"workflowRevisionId,omitempty"` + QueuedContactCount float64 `json:"queuedContactCount"` + Workflow *SimplifiedWorkflow `json:"workflow,omitempty"` } // ChangeWorkflowMailingList changes the mailing list of the workflow @@ -1312,6 +1313,7 @@ func (c *Client) ChangeWorkflowMailingList(id string, req ChangeWorkflowMailingL const ( WorkflowInsertModeBetween = "between" WorkflowInsertModeBefore = "before" + WorkflowInsertModeAfter = "after" ) // Node types that can be created via [Client.CreateWorkflowNode]. Trigger and @@ -1329,8 +1331,11 @@ const ( // InsertMode selects the placement: // - "between" inserts between FromNodeID and ToNodeID (FromNodeID must // currently point to ToNodeID); both are required. -// - "before" inserts before BeforeNodeID, which must have an incoming parent -// and cannot be a trigger node. +// - "before" inserts before ToNodeID, which must have an incoming parent and +// cannot be a trigger node. +// - "after" inserts after FromNodeID, valid only when FromNodeID has exactly +// one outgoing node (not zero, not multiple, and not an exit node). Use +// "between" with an explicit ToNodeID when the source has multiple outputs. // // ExpectedRevisionID is required and always sent (as JSON null when nil). // NodeTypeName is one of the CreateWorkflowNodeType* constants. @@ -1340,7 +1345,10 @@ type CreateWorkflowNodeRequest struct { NodeTypeName string FromNodeID string ToNodeID string - BeforeNodeID string + + // Deprecated: the API now expects ToNodeID for "before" mode. Set ToNodeID + // instead; BeforeNodeID is only sent when ToNodeID is empty. + BeforeNodeID string } // CreateWorkflowNodeResponse is returned by [Client.CreateWorkflowNode]. @@ -1363,7 +1371,13 @@ func (c *Client) CreateWorkflowNode(id string, req CreateWorkflowNodeRequest) (* body["fromNodeId"] = req.FromNodeID body["toNodeId"] = req.ToNodeID case WorkflowInsertModeBefore: - body["beforeNodeId"] = req.BeforeNodeID + if req.ToNodeID != "" { + body["toNodeId"] = req.ToNodeID + } else { + body["beforeNodeId"] = req.BeforeNodeID + } + case WorkflowInsertModeAfter: + body["fromNodeId"] = req.FromNodeID } b, err := json.Marshal(body) @@ -1508,8 +1522,33 @@ type UpdateWorkflowNodeRequest struct { } // UpdateWorkflowNodeResponse is the updated node returned by -// [Client.UpdateWorkflowNode], with the latest workflow revision token. -type UpdateWorkflowNodeResponse = WorkflowMutationNodeWithRevision +// [Client.UpdateWorkflowNode] (its fields stay flat via the embedded type) +// plus the latest simplified workflow. +type UpdateWorkflowNodeResponse struct { + WorkflowMutationNodeWithRevision + Workflow SimplifiedWorkflow `json:"workflow"` +} + +// UnmarshalJSON decodes the node and revision via the embedded type and then +// the latest workflow. +func (n *UpdateWorkflowNodeResponse) UnmarshalJSON(data []byte) error { + if err := n.WorkflowMutationNodeWithRevision.UnmarshalJSON(data); err != nil { + return err + } + var extra struct { + Workflow SimplifiedWorkflow `json:"workflow"` + } + if err := json.Unmarshal(data, &extra); err != nil { + return err + } + n.Workflow = extra.Workflow + return nil +} + +// MarshalJSON encodes the embedded node and revision and adds the workflow. +func (n UpdateWorkflowNodeResponse) MarshalJSON() ([]byte, error) { + return mergeMarshal(n.WorkflowMutationNodeWithRevision, map[string]any{"workflow": n.Workflow}) +} // UpdateWorkflowNode applies req.Payload to the node identified by // workflowID/nodeID and returns the updated node. @@ -1606,13 +1645,14 @@ type DeleteWorkflowNodeRequest struct { // DeleteWorkflowNodeResponse is returned by [Client.DeleteWorkflowNode] and // [Client.DeleteWorkflowNodeRecursive]. Status is one of the -// WorkflowMutationStatus* values. WorkflowRevisionID is set only when Status -// is "deleted". +// WorkflowMutationStatus* values. WorkflowRevisionID and Workflow are set only +// when Status is "deleted". type DeleteWorkflowNodeResponse struct { - Status string `json:"status"` - NodeIDs []string `json:"nodeIds"` - WorkflowRevisionID *string `json:"workflowRevisionId,omitempty"` - QueuedContactCount float64 `json:"queuedContactCount"` + Status string `json:"status"` + NodeIDs []string `json:"nodeIds"` + WorkflowRevisionID *string `json:"workflowRevisionId,omitempty"` + QueuedContactCount float64 `json:"queuedContactCount"` + Workflow *SimplifiedWorkflow `json:"workflow,omitempty"` } func (c *Client) deleteWorkflowNode(path string, req DeleteWorkflowNodeRequest) (*DeleteWorkflowNodeResponse, error) { @@ -1667,3 +1707,79 @@ func (c *Client) DeleteWorkflowNode(workflowID, nodeID string, req DeleteWorkflo func (c *Client) DeleteWorkflowNodeRecursive(workflowID, nodeID string, req DeleteWorkflowNodeRequest) (*DeleteWorkflowNodeResponse, error) { return c.deleteWorkflowNode("/workflows/"+workflowID+"/nodes/"+nodeID+"/recursive", req) } + +// RerouteNodeConnectionRequest is the request body for +// [Client.RerouteNodeConnection]. ExpectedRevisionID is required and always +// sent (as JSON null when nil). NewTargetNodeID is the valid node that should +// receive the source node's outgoing connection. +type RerouteNodeConnectionRequest struct { + ExpectedRevisionID *string + NewTargetNodeID string +} + +// RerouteNodeConnectionResponse is the updated source node returned by +// [Client.RerouteNodeConnection] (its fields stay flat via the embedded type) +// plus the latest simplified workflow. +type RerouteNodeConnectionResponse struct { + WorkflowMutationNodeWithRevision + Workflow SimplifiedWorkflow `json:"workflow"` +} + +// UnmarshalJSON decodes the node and revision via the embedded type and then +// the latest workflow. +func (n *RerouteNodeConnectionResponse) UnmarshalJSON(data []byte) error { + if err := n.WorkflowMutationNodeWithRevision.UnmarshalJSON(data); err != nil { + return err + } + var extra struct { + Workflow SimplifiedWorkflow `json:"workflow"` + } + if err := json.Unmarshal(data, &extra); err != nil { + return err + } + n.Workflow = extra.Workflow + return nil +} + +// MarshalJSON encodes the embedded node and revision and adds the workflow. +func (n RerouteNodeConnectionResponse) MarshalJSON() ([]byte, error) { + return mergeMarshal(n.WorkflowMutationNodeWithRevision, map[string]any{"workflow": n.Workflow}) +} + +// RerouteNodeConnection moves the single outgoing connection of the source node +// identified by workflowID/nodeID to req.NewTargetNodeID, returning the updated +// source node and the latest workflow. The source node must have exactly one +// outgoing connection; branch and experiment branch nodes cannot be rerouted. +func (c *Client) RerouteNodeConnection(workflowID, nodeID string, req RerouteNodeConnectionRequest) (*RerouteNodeConnectionResponse, error) { + body := map[string]any{ + "expectedRevisionId": req.ExpectedRevisionID, + "newTargetNodeId": req.NewTargetNodeID, + } + + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + httpReq, err := c.newRequest(http.MethodPost, "/workflows/"+workflowID+"/nodes/"+nodeID+"/reroute", bytes.NewReader(b)) + if err != nil { + return nil, err + } + + resp, err := c.do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result RerouteNodeConnectionResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} diff --git a/workflows_test.go b/workflows_test.go index 819f7d3..e0b0552 100644 --- a/workflows_test.go +++ b/workflows_test.go @@ -658,6 +658,9 @@ func TestChangeWorkflowMailingList_Preview(t *testing.T) { if res.WorkflowRevisionID != nil { t.Errorf("WorkflowRevisionID = %v, want nil", res.WorkflowRevisionID) } + if res.Workflow != nil { + t.Errorf("Workflow = %+v, want nil on queuedContactsFound", res.Workflow) + } } func TestChangeWorkflowMailingList_NullClear(t *testing.T) { @@ -669,7 +672,7 @@ func TestChangeWorkflowMailingList_NullClear(t *testing.T) { json.NewDecoder(tee).Decode(&gotBody) rawBody = buf.String() w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"updated","mailingListId":null,"workflowRevisionId":"rev_3","queuedContactCount":0}`)) + w.Write([]byte(`{"status":"updated","mailingListId":null,"workflowRevisionId":"rev_3","queuedContactCount":0,"workflow":{"id":"wf_1","status":"Draft","workflowRevisionId":"rev_3","mailingListId":null,"rootNodeId":"r","nodes":{}}}`)) })) defer server.Close() @@ -698,6 +701,9 @@ func TestChangeWorkflowMailingList_NullClear(t *testing.T) { if res.MailingListID != nil { t.Errorf("MailingListID = %v, want nil", res.MailingListID) } + if res.Workflow == nil || res.Workflow.ID != "wf_1" { + t.Errorf("Workflow = %+v, want id wf_1", res.Workflow) + } } func TestCreateWorkflowNode_Between(t *testing.T) { @@ -806,6 +812,124 @@ func TestCreateWorkflowNode_Before_NullRevision(t *testing.T) { } } +func TestCreateWorkflowNode_Before_ToNodeID(t *testing.T) { + var gotBody map[string]any + resp := `{ + "node": {"id":"node_new","typeName":"TimerAction","nextNodeIds":[],"amount":0,"unit":"m","workflowRevisionId":"rev_1"}, + "workflow": {"id":"wf_1","status":"Draft","workflowRevisionId":"rev_1","mailingListId":null,"rootNodeId":"r","nodes":{}} + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody = decodeBody(t, r) + w.WriteHeader(http.StatusOK) + w.Write([]byte(resp)) + })) + defer server.Close() + + client := NewClient("test-key", WithBaseURL(server.URL)) + _, err := client.CreateWorkflowNode("wf_1", CreateWorkflowNodeRequest{ + ExpectedRevisionID: ptr("rev_0"), + InsertMode: WorkflowInsertModeBefore, + NodeTypeName: CreateWorkflowNodeTypeTimerAction, + ToNodeID: "node_target", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["insertMode"] != "before" || gotBody["toNodeId"] != "node_target" { + t.Errorf("body = %+v", gotBody) + } + if _, ok := gotBody["beforeNodeId"]; ok { + t.Errorf("beforeNodeId should be absent when ToNodeID is set: %+v", gotBody) + } + if _, ok := gotBody["fromNodeId"]; ok { + t.Errorf("fromNodeId should be absent for before: %+v", gotBody) + } +} + +func TestCreateWorkflowNode_After(t *testing.T) { + var gotBody map[string]any + resp := `{ + "node": {"id":"node_new","typeName":"TimerAction","nextNodeIds":[],"amount":0,"unit":"m","workflowRevisionId":"rev_1"}, + "workflow": {"id":"wf_1","status":"Draft","workflowRevisionId":"rev_1","mailingListId":null,"rootNodeId":"r","nodes":{}} + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody = decodeBody(t, r) + if want := "/workflows/wf_1/nodes"; r.URL.Path != want { + t.Errorf("path = %q, want %q", r.URL.Path, want) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(resp)) + })) + defer server.Close() + + client := NewClient("test-key", WithBaseURL(server.URL)) + res, err := client.CreateWorkflowNode("wf_1", CreateWorkflowNodeRequest{ + ExpectedRevisionID: ptr("rev_0"), + InsertMode: WorkflowInsertModeAfter, + NodeTypeName: CreateWorkflowNodeTypeTimerAction, + FromNodeID: "node_a", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["insertMode"] != "after" || gotBody["fromNodeId"] != "node_a" { + t.Errorf("body = %+v", gotBody) + } + if _, ok := gotBody["toNodeId"]; ok { + t.Errorf("toNodeId should be absent for after: %+v", gotBody) + } + if _, ok := gotBody["beforeNodeId"]; ok { + t.Errorf("beforeNodeId should be absent for after: %+v", gotBody) + } + if res.Node.TypeName != WorkflowNodeTypeTimerAction { + t.Errorf("node = %+v", res.Node) + } +} + +func TestRerouteNodeConnection(t *testing.T) { + var gotBody map[string]any + resp := `{ + "id": "node_a", + "typeName": "SignupTrigger", + "nextNodeIds": ["node_c"], + "workflowRevisionId": "rev_31", + "workflow": {"id":"wf_1","status":"Draft","workflowRevisionId":"rev_31","mailingListId":null,"rootNodeId":"node_a","nodes":{}} + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody = decodeBody(t, r) + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if want := "/workflows/wf_1/nodes/node_a/reroute"; r.URL.Path != want { + t.Errorf("path = %q, want %q", r.URL.Path, want) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(resp)) + })) + defer server.Close() + + client := NewClient("test-key", WithBaseURL(server.URL)) + res, err := client.RerouteNodeConnection("wf_1", "node_a", RerouteNodeConnectionRequest{ + ExpectedRevisionID: ptr("rev_30"), + NewTargetNodeID: "node_c", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["expectedRevisionId"] != "rev_30" || gotBody["newTargetNodeId"] != "node_c" { + t.Errorf("body = %+v", gotBody) + } + if res.TypeName != WorkflowNodeTypeSignupTrigger { + t.Errorf("node typeName = %q", res.TypeName) + } + if res.WorkflowRevisionID != "rev_31" { + t.Errorf("node revision = %q, want rev_31", res.WorkflowRevisionID) + } + if res.Workflow.ID != "wf_1" { + t.Errorf("workflow = %+v", res.Workflow) + } +} + func TestUpdateWorkflowNode_Payloads(t *testing.T) { tests := []struct { name string @@ -922,7 +1046,8 @@ func TestUpdateWorkflowNode_RequestAndResponse(t *testing.T) { "nextNodeIds": ["n2"], "amount": 2, "unit": "h", - "workflowRevisionId": "rev_9" + "workflowRevisionId": "rev_9", + "workflow": {"id":"wf_1","status":"Draft","workflowRevisionId":"rev_9","mailingListId":null,"rootNodeId":"r","nodes":{}} }` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := new(strings.Builder) @@ -963,6 +1088,12 @@ func TestUpdateWorkflowNode_RequestAndResponse(t *testing.T) { if node.WorkflowRevisionID != "rev_9" { t.Errorf("revision = %q, want rev_9", node.WorkflowRevisionID) } + if node.Workflow.ID != "wf_1" { + t.Errorf("workflow = %+v", node.Workflow) + } + if node.Workflow.WorkflowRevisionID == nil || *node.Workflow.WorkflowRevisionID != "rev_9" { + t.Errorf("workflow revision = %v, want rev_9", node.Workflow.WorkflowRevisionID) + } } func TestAddWorkflowBranch(t *testing.T) { @@ -1013,7 +1144,7 @@ func TestDeleteWorkflowNode(t *testing.T) { gotPath = r.URL.Path gotBody = decodeBody(t, r) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"deleted","nodeIds":["node_x"],"workflowRevisionId":"rev_20","queuedContactCount":0}`)) + w.Write([]byte(`{"status":"deleted","nodeIds":["node_x"],"workflowRevisionId":"rev_20","queuedContactCount":0,"workflow":{"id":"wf_1","status":"Draft","workflowRevisionId":"rev_20","mailingListId":null,"rootNodeId":"r","nodes":{}}}`)) })) defer server.Close() @@ -1042,6 +1173,9 @@ func TestDeleteWorkflowNode(t *testing.T) { if len(res.NodeIDs) != 1 || res.NodeIDs[0] != "node_x" { t.Errorf("NodeIDs = %v", res.NodeIDs) } + if res.Workflow == nil || res.Workflow.ID != "wf_1" { + t.Errorf("Workflow = %+v, want id wf_1", res.Workflow) + } } func TestDeleteWorkflowNodeRecursive_DryRun(t *testing.T) { @@ -1076,6 +1210,9 @@ func TestDeleteWorkflowNodeRecursive_DryRun(t *testing.T) { if res.WorkflowRevisionID != nil { t.Errorf("WorkflowRevisionID = %v, want nil", res.WorkflowRevisionID) } + if res.Workflow != nil { + t.Errorf("Workflow = %+v, want nil on dry run", res.Workflow) + } if len(res.NodeIDs) != 2 { t.Errorf("NodeIDs = %v", res.NodeIDs) }