Skip to content
This repository has been archived by the owner on Jan 9, 2025. It is now read-only.

feat: add HubSpot component #199

Merged
merged 19 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 368 additions & 0 deletions application/hubspot/v0/README.mdx

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions application/hubspot/v0/assets/HubSpot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
241 changes: 241 additions & 0 deletions application/hubspot/v0/association.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package hubspot

import (
"fmt"

hubspot "github.com/belong-inc/go-hubspot"
"github.com/instill-ai/component/base"
"google.golang.org/protobuf/types/known/structpb"
)

// Retrieve Association is a custom feature
// Will implement it following go-hubspot sdk format

// API functions for Retrieve Association

type RetrieveAssociationService interface {
GetThreadID(contactID string) (*TaskRetrieveAssociationThreadResp, error)
GetCrmID(contactID string, objectType string) (*TaskRetrieveAssociationCrmResp, error)
}

type RetrieveAssociationServiceOp struct {
retrieveCrmIDPath string
retrieveThreadIDPath string
client *hubspot.Client
}

func (s *RetrieveAssociationServiceOp) GetThreadID(contactID string) (*TaskRetrieveAssociationThreadResp, error) {
resource := &TaskRetrieveAssociationThreadResp{}
if err := s.client.Get(s.retrieveThreadIDPath+contactID, resource, nil); err != nil {
return nil, err
}
return resource, nil
}

func (s *RetrieveAssociationServiceOp) GetCrmID(contactID string, objectType string) (*TaskRetrieveAssociationCrmResp, error) {
resource := &TaskRetrieveAssociationCrmResp{}

contactIDInput := TaskRetrieveAssociationCrmReqID{ContactID: contactID}

req := &TaskRetrieveAssociationCrmReq{}
req.Input = append(req.Input, contactIDInput)

path := s.retrieveCrmIDPath + "/" + objectType + "/batch/read"

if err := s.client.Post(path, req, resource); err != nil {
return nil, err
}
return resource, nil
}

// Retrieve Association: use contact id to get the object ID associated with it

type TaskRetrieveAssociationInput struct {
ContactID string `json:"contact-id"`
ObjectType string `json:"object-type"`
}

// Retrieve Association Task is mainly divided into two:
// 1. GetThreadID
// 2. GetCrmID
// Basically, these two will have seperate structs for handling request/response

// For GetThreadID

type TaskRetrieveAssociationThreadResp struct {
Results []struct {
ID string `json:"id"`
} `json:"results"`
}

// For GetCrmID

type TaskRetrieveAssociationCrmReq struct {
Input []TaskRetrieveAssociationCrmReqID `json:"inputs"`
}

type TaskRetrieveAssociationCrmReqID struct {
ContactID string `json:"id"`
}

type TaskRetrieveAssociationCrmResp struct {
Results []taskRetrieveAssociationCrmRespResult `json:"results"`
}

type taskRetrieveAssociationCrmRespResult struct {
IDArray []struct {
ID string `json:"id"`
} `json:"to"`
}

// Retrieve Association Output

type TaskRetrieveAssociationOutput struct {
ObjectIDs []string `json:"object-ids"`
}

func (e *execution) RetrieveAssociation(input *structpb.Struct) (*structpb.Struct, error) {
inputStruct := TaskRetrieveAssociationInput{}
err := base.ConvertFromStructpb(input, &inputStruct)

if err != nil {
return nil, err
}

// API calls to retrieve association for Threads and CRM objects are different

var objectIDs []string
if inputStruct.ObjectType == "Threads" {

// To handle Threads
res, err := e.client.RetrieveAssociation.GetThreadID(inputStruct.ContactID)

if err != nil {
return nil, err
}

if len(res.Results) == 0 {
return nil, fmt.Errorf("no object ID found")
}

objectIDs = make([]string, len(res.Results))
for index, value := range res.Results {
objectIDs[index] = value.ID
}

} else {

// To handle CRM objects
res, err := e.client.RetrieveAssociation.GetCrmID(inputStruct.ContactID, inputStruct.ObjectType)

if err != nil {
return nil, err
}

if len(res.Results) == 0 {
return nil, fmt.Errorf("no object ID found")
}

// only take the first Result, because the input is only one contact id
objectIDs = make([]string, len(res.Results[0].IDArray))
for index, value := range res.Results[0].IDArray {
objectIDs[index] = value.ID
}

}

outputStruct := TaskRetrieveAssociationOutput{
ObjectIDs: objectIDs,
}

output, err := base.ConvertToStructpb(outputStruct)

if err != nil {
return nil, err
}

return output, nil
}

// Create Association (not a task)
// This section (create association) is used in:
// create contact task to create contact -> objects (company, ticket, deal) association
// create company task to create company -> contact association
// create deal task to create deal -> contact association
// create ticket task to create ticket -> contact association

type CreateAssociationReq struct {
Associations []association `json:"inputs"`
}

type association struct {
From struct {
ID string `json:"id"`
} `json:"from"`
To struct {
ID string `json:"id"`
} `json:"to"`
Type string `json:"type"`
}

type CreateAssociationResponse struct {
Status string `json:"status"`
}

// CreateAssociation is used to create batch associations between objects

func CreateAssociation(fromID *string, toIDs *[]string, fromObjectType string, toObjectType string, e *execution) error {
req := &CreateAssociationReq{
Associations: make([]association, len(*toIDs)),
}

//for any association created related to company, it will use non-primary label.
//for more info: https://developers.hubspot.com/beta-docs/guides/api/crm/associations#association-type-id-values

var associationType string
if toObjectType == "company" {
switch fromObjectType { //use switch here in case other association of object -> company want to be created in the future
case "contact":
associationType = "279"
}

} else if fromObjectType == "company" {
switch toObjectType {
case "contact":
associationType = "280"
}
} else {
associationType = fmt.Sprintf("%s_to_%s", fromObjectType, toObjectType)
}

for index, toID := range *toIDs {

req.Associations[index] = association{
From: struct {
ID string `json:"id"`
}{
ID: *fromID,
},
To: struct {
ID string `json:"id"`
}{
ID: toID,
},
Type: associationType,
}
}

createAssociationPath := fmt.Sprintf("crm/v3/associations/%s/%s/batch/create", fromObjectType, toObjectType)

resp := &CreateAssociationResponse{}

if err := e.client.Post(createAssociationPath, req, resp); err != nil {
return err
}

if resp.Status != "COMPLETE" {
return fmt.Errorf("failed to create association")
}

return nil
}
121 changes: 121 additions & 0 deletions application/hubspot/v0/association_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package hubspot

import (
"context"
"testing"

qt "github.com/frankban/quicktest"
"github.com/instill-ai/component/base"
"go.uber.org/zap"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
)

// mockClient is in contact_test.go

// Mock Retrieve Association struct and its functions

type MockRetrieveAssociation struct{}

func (s *MockRetrieveAssociation) GetThreadID(contactID string) (*TaskRetrieveAssociationThreadResp, error) {

var fakeThreadID TaskRetrieveAssociationThreadResp
if contactID == "32027696539" {
fakeThreadID = TaskRetrieveAssociationThreadResp{
Results: []struct {
ID string `json:"id"`
}{
{ID: "7509711154"},
},
}
}
return &fakeThreadID, nil
}

func (s *MockRetrieveAssociation) GetCrmID(contactID string, objectType string) (*TaskRetrieveAssociationCrmResp, error) {

var fakeCrmID TaskRetrieveAssociationCrmResp
if contactID == "32027696539" {
fakeCrmID = TaskRetrieveAssociationCrmResp{
Results: []taskRetrieveAssociationCrmRespResult{
{
IDArray: []struct {
ID string `json:"id"`
}{
{ID: "12345678900"},
},
},
},
}
}
return &fakeCrmID, nil

}

func TestComponent_ExecuteRetrieveAssociationTask(t *testing.T) {
c := qt.New(t)
ctx := context.Background()
bc := base.Component{Logger: zap.NewNop()}
connector := Init(bc)

testcases := []struct {
name string
input TaskRetrieveAssociationInput
wantResp interface{}
}{
{
name: "ok - retrieve association: thread ID",
input: TaskRetrieveAssociationInput{
ContactID: "32027696539",
ObjectType: "Threads",
},
wantResp: TaskRetrieveAssociationOutput{
ObjectIDs: []string{
"7509711154",
},
},
},
{
name: "ok - retrieve association: deal ID",
input: TaskRetrieveAssociationInput{
ContactID: "32027696539",
ObjectType: "Deals",
},
wantResp: TaskRetrieveAssociationOutput{
ObjectIDs: []string{
"12345678900",
},
},
},
}

for _, tc := range testcases {
c.Run(tc.name, func(c *qt.C) {
setup, err := structpb.NewStruct(map[string]any{
"token": bearerToken,
})
c.Assert(err, qt.IsNil)

e := &execution{
ComponentExecution: base.ComponentExecution{Component: connector, SystemVariables: nil, Setup: setup, Task: taskRetrieveAssociation},
client: createMockClient(),
}
e.execute = e.RetrieveAssociation
exec := &base.ExecutionWrapper{Execution: e}

pbInput, err := base.ConvertToStructpb(tc.input)

c.Assert(err, qt.IsNil)

res, err := exec.Execution.Execute(ctx, []*structpb.Struct{pbInput})
c.Assert(err, qt.IsNil)

resJSON, err := protojson.Marshal(res[0])
c.Assert(err, qt.IsNil)

c.Check(resJSON, qt.JSONEquals, tc.wantResp)

})
}

}
Loading
Loading