diff --git a/api/automation.go b/api/automation.go index a35082b..0af7300 100644 --- a/api/automation.go +++ b/api/automation.go @@ -73,3 +73,120 @@ func (c *Client) GetWorkflow(workflowID string) (*Workflow, error) { return &result, nil } + +// CreateWorkflow creates a new workflow +func (c *Client) CreateWorkflow(data map[string]interface{}) (*Workflow, error) { + url := fmt.Sprintf("%s/automation/v4/flows", c.BaseURL) + + body, err := c.post(url, data) + if err != nil { + return nil, err + } + + var result Workflow + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse workflow response: %w", err) + } + + return &result, nil +} + +// UpdateWorkflow updates an existing workflow +func (c *Client) UpdateWorkflow(workflowID string, data map[string]interface{}) (*Workflow, error) { + if workflowID == "" { + return nil, fmt.Errorf("workflow ID is required") + } + + url := fmt.Sprintf("%s/automation/v4/flows/%s", c.BaseURL, workflowID) + + body, err := c.patch(url, data) + if err != nil { + return nil, err + } + + var result Workflow + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse workflow response: %w", err) + } + + return &result, nil +} + +// DeleteWorkflow deletes a workflow by ID +func (c *Client) DeleteWorkflow(workflowID string) error { + if workflowID == "" { + return fmt.Errorf("workflow ID is required") + } + + url := fmt.Sprintf("%s/automation/v4/flows/%s", c.BaseURL, workflowID) + + _, err := c.delete(url) + return err +} + +// WorkflowEnrollment represents an enrollment in a workflow +type WorkflowEnrollment struct { + ObjectID string `json:"objectId"` + ObjectType string `json:"objectType,omitempty"` + EnrolledAt string `json:"enrolledAt,omitempty"` + Status string `json:"status,omitempty"` + EnrollmentID string `json:"enrollmentId,omitempty"` +} + +// WorkflowEnrollmentList represents a paginated list of enrollments +type WorkflowEnrollmentList struct { + Results []WorkflowEnrollment `json:"results"` + Paging *Paging `json:"paging,omitempty"` +} + +// EnrollInWorkflow enrolls an object in a workflow +func (c *Client) EnrollInWorkflow(workflowID string, objectID string) error { + if workflowID == "" { + return fmt.Errorf("workflow ID is required") + } + if objectID == "" { + return fmt.Errorf("object ID is required") + } + + url := fmt.Sprintf("%s/automation/v4/flows/%s/enrollments/start", c.BaseURL, workflowID) + + data := map[string]interface{}{ + "objectId": objectID, + } + + _, err := c.post(url, data) + return err +} + +// ListWorkflowEnrollments lists enrollments for a workflow +func (c *Client) ListWorkflowEnrollments(workflowID string, opts ListOptions) (*WorkflowEnrollmentList, error) { + if workflowID == "" { + return nil, fmt.Errorf("workflow ID is required") + } + + url := fmt.Sprintf("%s/automation/v4/flows/%s/enrollments", c.BaseURL, workflowID) + + params := make(map[string]string) + if opts.Limit > 0 { + params["limit"] = strconv.Itoa(opts.Limit) + } + if opts.After != "" { + params["after"] = opts.After + } + + if len(params) > 0 { + url = buildURL(url, params) + } + + body, err := c.get(url) + if err != nil { + return nil, err + } + + var result WorkflowEnrollmentList + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse enrollments response: %w", err) + } + + return &result, nil +} diff --git a/api/automation_test.go b/api/automation_test.go index c33da5b..28c2511 100644 --- a/api/automation_test.go +++ b/api/automation_test.go @@ -135,3 +135,209 @@ func TestClient_GetWorkflow(t *testing.T) { assert.Nil(t, workflow) }) } + +func TestClient_CreateWorkflow(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/automation/v4/flows", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "id": "workflow-456", + "name": "New Workflow", + "type": "CONTACT_FLOW", + "isEnabled": false, + "createdAt": "2024-01-20T10:00:00Z" + }`)) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + data := map[string]interface{}{ + "name": "New Workflow", + "type": "CONTACT_FLOW", + } + workflow, err := client.CreateWorkflow(data) + require.NoError(t, err) + assert.Equal(t, "workflow-456", workflow.ID) + assert.Equal(t, "New Workflow", workflow.Name) + }) +} + +func TestClient_UpdateWorkflow(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/automation/v4/flows/workflow-123", r.URL.Path) + assert.Equal(t, http.MethodPatch, r.Method) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": "workflow-123", + "name": "Updated Workflow", + "type": "CONTACT_FLOW", + "isEnabled": true, + "updatedAt": "2024-01-21T10:00:00Z" + }`)) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + updates := map[string]interface{}{ + "name": "Updated Workflow", + } + workflow, err := client.UpdateWorkflow("workflow-123", updates) + require.NoError(t, err) + assert.Equal(t, "workflow-123", workflow.ID) + assert.Equal(t, "Updated Workflow", workflow.Name) + }) + + t.Run("empty ID", func(t *testing.T) { + client := &Client{BaseURL: "https://api.hubapi.com"} + workflow, err := client.UpdateWorkflow("", map[string]interface{}{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "workflow ID is required") + assert.Nil(t, workflow) + }) +} + +func TestClient_DeleteWorkflow(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/automation/v4/flows/workflow-123", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + err := client.DeleteWorkflow("workflow-123") + require.NoError(t, err) + }) + + t.Run("empty ID", func(t *testing.T) { + client := &Client{BaseURL: "https://api.hubapi.com"} + err := client.DeleteWorkflow("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "workflow ID is required") + }) +} + +func TestClient_EnrollInWorkflow(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/automation/v4/flows/workflow-123/enrollments/start", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + err := client.EnrollInWorkflow("workflow-123", "contact-456") + require.NoError(t, err) + }) + + t.Run("empty workflow ID", func(t *testing.T) { + client := &Client{BaseURL: "https://api.hubapi.com"} + err := client.EnrollInWorkflow("", "contact-456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "workflow ID is required") + }) + + t.Run("empty object ID", func(t *testing.T) { + client := &Client{BaseURL: "https://api.hubapi.com"} + err := client.EnrollInWorkflow("workflow-123", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "object ID is required") + }) +} + +func TestClient_ListWorkflowEnrollments(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/automation/v4/flows/workflow-123/enrollments", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "results": [ + { + "objectId": "contact-456", + "objectType": "CONTACT", + "status": "ACTIVE", + "enrolledAt": "2024-01-20T10:00:00Z" + } + ], + "paging": { + "next": { + "after": "abc123" + } + } + }`)) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + result, err := client.ListWorkflowEnrollments("workflow-123", ListOptions{Limit: 10}) + require.NoError(t, err) + assert.Len(t, result.Results, 1) + assert.Equal(t, "contact-456", result.Results[0].ObjectID) + assert.Equal(t, "CONTACT", result.Results[0].ObjectType) + assert.Equal(t, "ACTIVE", result.Results[0].Status) + assert.Equal(t, "abc123", result.Paging.Next.After) + }) + + t.Run("empty workflow ID", func(t *testing.T) { + client := &Client{BaseURL: "https://api.hubapi.com"} + result, err := client.ListWorkflowEnrollments("", ListOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "workflow ID is required") + assert.Nil(t, result) + }) + + t.Run("empty results", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"results": []}`)) + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "test-token", + HTTPClient: server.Client(), + } + + result, err := client.ListWorkflowEnrollments("workflow-123", ListOptions{}) + require.NoError(t, err) + assert.Empty(t, result.Results) + }) +} diff --git a/internal/cmd/workflows/workflows.go b/internal/cmd/workflows/workflows.go index 3b10809..3cc04e6 100644 --- a/internal/cmd/workflows/workflows.go +++ b/internal/cmd/workflows/workflows.go @@ -1,6 +1,10 @@ package workflows import ( + "encoding/json" + "fmt" + "os" + "github.com/spf13/cobra" "github.com/open-cli-collective/hubspot-cli/api" @@ -12,11 +16,16 @@ func Register(parent *cobra.Command, opts *root.Options) { cmd := &cobra.Command{ Use: "workflows", Short: "Manage HubSpot workflows", - Long: "Commands for listing and viewing automation workflows.", + Long: "Commands for listing, viewing, creating, updating, and deleting automation workflows, plus enrollment operations.", } cmd.AddCommand(newListCmd(opts)) cmd.AddCommand(newGetCmd(opts)) + cmd.AddCommand(newCreateCmd(opts)) + cmd.AddCommand(newUpdateCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newEnrollCmd(opts)) + cmd.AddCommand(newEnrollmentsCmd(opts)) parent.AddCommand(cmd) } @@ -155,3 +164,244 @@ func formatObjectType(objectTypeID string) string { } return objectTypeID } + +func newCreateCmd(opts *root.Options) *cobra.Command { + var file string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a workflow", + Long: "Create a new automation workflow from a JSON file.", + Example: ` # Create a workflow from JSON file + hspt workflows create --file workflow.json`, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + if file == "" { + return fmt.Errorf("--file is required") + } + + data, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var workflowData map[string]interface{} + if err := json.Unmarshal(data, &workflowData); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + workflow, err := client.CreateWorkflow(workflowData) + if err != nil { + return err + } + + v.Success("Workflow created: %s (ID: %s)", workflow.Name, workflow.ID) + return nil + }, + } + + cmd.Flags().StringVar(&file, "file", "", "JSON file containing workflow definition (required)") + + return cmd +} + +func newUpdateCmd(opts *root.Options) *cobra.Command { + var file string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a workflow", + Long: "Update an existing automation workflow from a JSON file.", + Example: ` # Update a workflow from JSON file + hspt workflows update 12345 --file workflow.json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + id := args[0] + + if file == "" { + return fmt.Errorf("--file is required") + } + + data, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var workflowData map[string]interface{} + if err := json.Unmarshal(data, &workflowData); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + workflow, err := client.UpdateWorkflow(id, workflowData) + if err != nil { + if api.IsNotFound(err) { + v.Error("Workflow %s not found", id) + return nil + } + return err + } + + v.Success("Workflow updated: %s (ID: %s)", workflow.Name, workflow.ID) + return nil + }, + } + + cmd.Flags().StringVar(&file, "file", "", "JSON file containing workflow updates (required)") + + return cmd +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a workflow", + Long: "Delete an automation workflow by ID.", + Example: ` # Delete a workflow + hspt workflows delete 12345`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + id := args[0] + + client, err := opts.APIClient() + if err != nil { + return err + } + + err = client.DeleteWorkflow(id) + if err != nil { + if api.IsNotFound(err) { + v.Error("Workflow %s not found", id) + return nil + } + return err + } + + v.Success("Workflow %s deleted", id) + return nil + }, + } +} + +func newEnrollCmd(opts *root.Options) *cobra.Command { + var objectID string + + cmd := &cobra.Command{ + Use: "enroll ", + Short: "Enroll an object in a workflow", + Long: "Enroll a contact, company, deal, or other object in a workflow.", + Example: ` # Enroll a contact in a workflow + hspt workflows enroll 12345 --object-id 67890`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + workflowID := args[0] + + if objectID == "" { + return fmt.Errorf("--object-id is required") + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + err = client.EnrollInWorkflow(workflowID, objectID) + if err != nil { + if api.IsNotFound(err) { + v.Error("Workflow %s not found", workflowID) + return nil + } + return err + } + + v.Success("Object %s enrolled in workflow %s", objectID, workflowID) + return nil + }, + } + + cmd.Flags().StringVar(&objectID, "object-id", "", "ID of the object to enroll (required)") + + return cmd +} + +func newEnrollmentsCmd(opts *root.Options) *cobra.Command { + var limit int + var after string + + cmd := &cobra.Command{ + Use: "enrollments ", + Short: "List workflow enrollments", + Long: "List objects enrolled in a workflow.", + Example: ` # List enrollments for a workflow + hspt workflows enrollments 12345 + + # List with pagination + hspt workflows enrollments 12345 --limit 50`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + workflowID := args[0] + + client, err := opts.APIClient() + if err != nil { + return err + } + + result, err := client.ListWorkflowEnrollments(workflowID, api.ListOptions{ + Limit: limit, + After: after, + }) + if err != nil { + if api.IsNotFound(err) { + v.Error("Workflow %s not found", workflowID) + return nil + } + return err + } + + if len(result.Results) == 0 { + v.Info("No enrollments found for workflow %s", workflowID) + return nil + } + + headers := []string{"OBJECT ID", "OBJECT TYPE", "STATUS", "ENROLLED AT"} + rows := make([][]string, 0, len(result.Results)) + for _, enrollment := range result.Results { + rows = append(rows, []string{ + enrollment.ObjectID, + enrollment.ObjectType, + enrollment.Status, + enrollment.EnrolledAt, + }) + } + + if err := v.Render(headers, rows, result); err != nil { + return err + } + + if result.Paging != nil && result.Paging.Next != nil { + v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) + } + + return nil + }, + } + + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of enrollments to return") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") + + return cmd +}