Repository: amp-labs/connectors
Pull request #1373 (2025-02-17 12:28:38)

[CON-1063] feat: Add Freshdesk Write Connector
Tests:
<img width="1225" alt="Screenshot 2025-02-17 at 15 14 49" src="https://github.com/user-attachments/assets/22b06185-20f2-4566-bfe9-0d2cd4fb745c" />



Patch:
diff --git a/providers/freshdesk/connector.go b/providers/freshdesk/connector.go
index 0bdbdb23..38580197 100644
--- a/providers/freshdesk/connector.go
+++ b/providers/freshdesk/connector.go
@@ -43,7 +43,7 @@ func (conn *Connector) Provider() providers.Provider {
 }
 
 func (conn *Connector) getAPIURL(objectName string) (*urlbuilder.URL, error) {
-	path, exists := objectReadPath[objectName]
+	path, exists := objectResourcePath[objectName]
 	if !exists {
 		path = objectName
 	}
diff --git a/providers/freshdesk/metadata.go b/providers/freshdesk/metadata.go
index b3a12bc4..e9aac359 100644
--- a/providers/freshdesk/metadata.go
+++ b/providers/freshdesk/metadata.go
@@ -21,7 +21,7 @@ func (conn *Connector) ListObjectMetadata(ctx context.Context,
 	}
 
 	for _, object := range objectNames {
-		if !objectReadSupported(object) {
+		if !readSupportedObjects.Has(object) {
 			metadataResults.Errors[object] = common.ErrObjectNotSupported
 
 			continue
diff --git a/providers/freshdesk/types.go b/providers/freshdesk/types.go
index a047bbf1..77d62f8f 100644
--- a/providers/freshdesk/types.go
+++ b/providers/freshdesk/types.go
@@ -1,37 +1,57 @@
 package freshdesk
 
-import "slices"
+import (
+	"github.com/amp-labs/connectors/internal/datautils"
+)
 
 // readSupportedObjects represents a list of objects supported by Read Connector.
-var readSupportedObjects = []string{ //nolint:gochecknoglobals
-	"contacts",
-	"tickets",
-	"ticket-forms",
+var readSupportedObjects = datautils.NewSet( //nolint:gochecknoglobals
 	"agents",
-	"roles",
-	"groups",
-	"companies",
+	"business_hours",
 	"canned_response_folders",
-	"surveys",
-	"time_entries",
+	"companies",
+	"company-fields",
+	"contacts",
+	"contact-fields",
 	"email_configs",
+	"groups",
+	"mailboxes",
 	"products",
-	"business_hours",
+	"roles",
 	"scenario_automations",
+	"settings",
+	"skills",
 	"sla_policies",
-}
-
-// objectReadPath represents a mapping of an object to it's read path.
-var objectReadPath = map[string]string{ //nolint:gochecknoglobals
-	"mailboxes": "email/mailboxes",
-	"settings":  "settings/helpdesk",
-	"skills":    "admin/skills",
-}
+	"surveys",
+	"ticket-fields",
+	"ticket-forms",
+	"tickets",
+	"time_entries",
+)
 
-func objectReadSupported(objectName string) bool {
-	if _, exists := objectReadPath[objectName]; exists {
-		return exists
-	}
+var writeSupportedObjects = datautils.NewSet( //nolint:gochecknoglobals
+	"agents",
+	"canned_response_folders",
+	"companies",
+	"company-fields",
+	"contact-activities",
+	"contact-fields",
+	"contacts",
+	"groups",
+	"mailboxes",
+	"skills",
+	"sla_policies",
+	"thread",
+	"ticket-fields",
+	"ticket-forms",
+	"tickets",
+)
 
-	return slices.Contains(readSupportedObjects, objectName)
+// objectResourcePath represents a mapping of an object to it's read/write resource.
+var objectResourcePath = map[string]string{ //nolint:gochecknoglobals
+	"mailboxes":     "email/mailboxes",
+	"settings":      "settings/helpdesk",
+	"skills":        "admin/skills",
+	"thread":        "collaboration/threads",
+	"ticket-fields": "admin/ticket_fields",
 }
diff --git a/providers/freshdesk/url.go b/providers/freshdesk/url.go
index ce7f3dba..d344222b 100644
--- a/providers/freshdesk/url.go
+++ b/providers/freshdesk/url.go
@@ -19,7 +19,7 @@ func (conn *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL,
 		return urlbuilder.New(string(config.NextPage))
 	}
 
-	if !objectReadSupported(config.ObjectName) {
+	if !readSupportedObjects.Has(config.ObjectName) {
 		return nil, common.ErrObjectNotSupported
 	}
 
diff --git a/providers/freshdesk/write.go b/providers/freshdesk/write.go
new file mode 100644
index 00000000..dc6b73d8
--- /dev/null
+++ b/providers/freshdesk/write.go
@@ -0,0 +1,68 @@
+package freshdesk
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/logging"
+)
+
+func (conn *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	ctx = logging.With(ctx, "connector", "freshdesk")
+	write := conn.Client.Post
+
+	if err := config.ValidateParams(); err != nil {
+		return nil, err
+	}
+
+	if !writeSupportedObjects.Has(config.ObjectName) {
+		return nil, common.ErrObjectNotSupported
+	}
+
+	url, err := conn.getAPIURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(config.RecordId) > 0 {
+		url.AddPath(config.RecordId)
+
+		write = conn.Client.Put
+	}
+
+	resp, err := write(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return constructWriteResponse(ctx, config.ObjectName, resp), nil
+}
+
+func constructWriteResponse(ctx context.Context, objectName string, resp *common.JSONHTTPResponse) *common.WriteResult {
+	res, err := common.UnmarshalJSON[map[string]any](resp)
+	if err != nil {
+		logging.Logger(ctx).Warn("failed to unmarshal response: ", "error: ", err.Error(), "objectName: ", objectName)
+
+		return &common.WriteResult{
+			Success: true,
+		}
+	}
+
+	data := *res
+
+	recordId, ok := data["id"].(float64)
+	if !ok {
+		logging.Logger(ctx).Warn("failed to cast 'id' field to float64", "objectName: ", objectName)
+
+		return &common.WriteResult{
+			Success: true,
+		}
+	}
+
+	return &common.WriteResult{
+		RecordId: strconv.Itoa(int(recordId)),
+		Success:  true,
+		Data:     data,
+	}
+}


Test patch:
diff --git a/providers/freshdesk/test/create-contacts.json b/providers/freshdesk/test/create-contacts.json
new file mode 100644
index 00000000..192a9a41
--- /dev/null
+++ b/providers/freshdesk/test/create-contacts.json
@@ -0,0 +1,14 @@
+{
+  "active": false,
+  "deleted": false,
+  "email": "superman@freshdesk.com",
+  "id": 501002381517,
+  "name": "Super Man",
+  "time_zone": "Eastern Time (US & Canada)",
+  "created_at": "2025-02-17T11:05:13Z",
+  "updated_at": "2025-02-17T11:05:13Z",
+  "first_name": "Super",
+  "last_name": "Man",
+  "visitor_id": "df69c082-f2e6-4874-ad05-2ecfc7e27621",
+  "org_contact_id": 1891443779623272448
+}
diff --git a/providers/freshdesk/test/update-ticket.json b/providers/freshdesk/test/update-ticket.json
new file mode 100644
index 00000000..eb91b4af
--- /dev/null
+++ b/providers/freshdesk/test/update-ticket.json
@@ -0,0 +1,25 @@
+{
+  "cc_emails": ["ram@freshdesk.com", "diana@freshdesk.com"],
+  "reply_cc_emails": ["ram@freshdesk.com", "diana@freshdesk.com"],
+  "ticket_cc_emails": ["ram@freshdesk.com", "diana@freshdesk.com"],
+  "spam": false,
+  "email_config_id": 501000047756,
+  "fr_escalated": false,
+  "priority": 1,
+  "requester_id": 501002381489,
+  "source": 2,
+  "status": 2,
+  "subject": "Support Needed...ASAP",
+  "description": "<div>Details about the issue...</div>",
+  "description_text": "Details about the issue...",
+  "id": 3,
+  "product_id": 501000043986,
+  "is_escalated": false,
+  "nr_escalated": false,
+  "created_at": "2025-02-17T11:00:26Z",
+  "updated_at": "2025-02-17T11:15:00Z",
+  "due_by": "2025-02-19T22:00:00Z",
+  "fr_due_by": "2025-02-17T22:00:00Z",
+  "form_id": 501000123310,
+  "sentiment_score": 44
+}
diff --git a/providers/freshdesk/write_test.go b/providers/freshdesk/write_test.go
new file mode 100644
index 00000000..90bc2687
--- /dev/null
+++ b/providers/freshdesk/write_test.go
@@ -0,0 +1,131 @@
+package freshdesk
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	contact := testutils.DataFromFile(t, "create-contacts.json")
+	ticket := testutils.DataFromFile(t, "update-ticket.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write needs data payload",
+			Input:        common.WriteParams{ObjectName: "contacts"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+		{
+			Name:  "Unsupported object name",
+			Input: common.WriteParams{ObjectName: "butterflies", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusNotFound, nil),
+			}.Server(),
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
+		},
+		{
+			Name:  "Creation of a Contact",
+			Input: common.WriteParams{ObjectName: "contacts", RecordData: "realdata"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/contacts"),
+					mockcond.MethodPOST(),
+				},
+				Then: mockserver.Response(http.StatusOK, contact),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "501002381517",
+				Data: map[string]any{
+					"active":         false,
+					"deleted":        false,
+					"email":          "superman@freshdesk.com",
+					"id":             float64(501002381517),
+					"name":           "Super Man",
+					"time_zone":      "Eastern Time (US & Canada)",
+					"created_at":     "2025-02-17T11:05:13Z",
+					"updated_at":     "2025-02-17T11:05:13Z",
+					"first_name":     "Super",
+					"last_name":      "Man",
+					"visitor_id":     "df69c082-f2e6-4874-ad05-2ecfc7e27621",
+					"org_contact_id": float64(1891443779623272448),
+				},
+			},
+		},
+		{
+			Name: "Update of a ticket via PUT",
+			Input: common.WriteParams{
+				ObjectName: "tickets",
+				RecordId:   "3",
+				RecordData: "somenewdata",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/tickets/3"),
+					mockcond.MethodPUT(),
+				},
+				Then: mockserver.Response(http.StatusOK, ticket),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "3",
+				Data: map[string]any{
+					"cc_emails":        []any{"ram@freshdesk.com", "diana@freshdesk.com"},
+					"reply_cc_emails":  []any{"ram@freshdesk.com", "diana@freshdesk.com"},
+					"ticket_cc_emails": []any{"ram@freshdesk.com", "diana@freshdesk.com"},
+					"spam":             false,
+					"email_config_id":  float64(501000047756),
+					"fr_escalated":     false,
+					"priority":         float64(1),
+					"requester_id":     float64(501002381489),
+					"source":           float64(2),
+					"status":           float64(2),
+					"subject":          "Support Needed...ASAP",
+					"description":      "<div>Details about the issue...</div>",
+					"description_text": "Details about the issue...",
+					"id":               float64(3),
+					"product_id":       float64(501000043986),
+					"is_escalated":     false,
+					"nr_escalated":     false,
+					"created_at":       "2025-02-17T11:00:26Z",
+					"updated_at":       "2025-02-17T11:15:00Z",
+					"due_by":           "2025-02-19T22:00:00Z",
+					"fr_due_by":        "2025-02-17T22:00:00Z",
+					"form_id":          float64(501000123310),
+					"sentiment_score":  float64(44),
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/freshdesk/write/main.go b/test/freshdesk/write/main.go
new file mode 100644
index 00000000..267958bb
--- /dev/null
+++ b/test/freshdesk/write/main.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	fd "github.com/amp-labs/connectors/providers/freshdesk"
+	"github.com/amp-labs/connectors/test/freshdesk"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+		os.Exit(-1)
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+	conn := freshdesk.GetFreshdeskConnector(ctx)
+
+	err := testCreatingTicket(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testUpdateTicket(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testCreatingContacts(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testCreatingTicket(ctx context.Context, conn *fd.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "tickets",
+		RecordData: map[string]any{
+			"description": "Details about the issue...",
+			"subject":     "Support Needed...",
+			"email":       "tom@outerspace.com",
+			"priority":    1,
+			"status":      2,
+			"cc_emails":   []string{"ram@freshdesk.com", "diana@freshdesk.com"},
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testUpdateTicket(ctx context.Context, conn *fd.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "tickets",
+		RecordId:   "3",
+		RecordData: map[string]any{
+			"subject": "Support Needed... ASAP",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testCreatingContacts(ctx context.Context, conn *fd.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "contacts",
+		RecordData: map[string]any{
+			"name":         "Super Man",
+			"email":        gofakeit.Email(),
+			"other_emails": []string{gofakeit.Email()},
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}