mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 16:02:13 +00:00
580 lines
18 KiB
Go
580 lines
18 KiB
Go
package hubspot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// HubSpot-defined association type ids for the pairs Warmbly writes.
|
|
const (
|
|
assocContactToCompany = 279
|
|
assocDealToContact = 3
|
|
assocDealToCompany = 341
|
|
assocTaskToContact = 204
|
|
assocTaskToDeal = 216
|
|
assocNoteToContact = 202
|
|
assocNoteToDeal = 214
|
|
assocEmailToContact = 198
|
|
assocEmailToDeal = 210
|
|
assocEmailToCompany = 186
|
|
assocMeetingToContact = 200
|
|
assocMeetingToDeal = 212
|
|
)
|
|
|
|
// Object is any CRM record: contact, deal, task, note, email, meeting.
|
|
type Object struct {
|
|
ID string `json:"id"`
|
|
Properties map[string]*string `json:"properties"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Archived bool `json:"archived"`
|
|
Associations map[string]associationPage `json:"associations,omitempty"`
|
|
}
|
|
|
|
type associationPage struct {
|
|
Results []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
} `json:"results"`
|
|
}
|
|
|
|
// Prop returns a property, "" when unset.
|
|
func (o Object) Prop(name string) string {
|
|
if v, ok := o.Properties[name]; ok && v != nil {
|
|
return *v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Associated lists the ids associated under one object type.
|
|
func (o Object) Associated(objectType string) []string {
|
|
page, ok := o.Associations[objectType]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, r := range page.Results {
|
|
if !seen[r.ID] {
|
|
seen[r.ID] = true
|
|
out = append(out, r.ID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Assoc is an association written with a create.
|
|
type Assoc struct {
|
|
ToID string
|
|
TypeID int
|
|
}
|
|
|
|
func assocBody(assocs []Assoc) []map[string]any {
|
|
out := make([]map[string]any, 0, len(assocs))
|
|
for _, a := range assocs {
|
|
if a.ToID == "" {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{
|
|
"to": map[string]any{"id": a.ToID},
|
|
"types": []map[string]any{{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": a.TypeID}},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Owner is a HubSpot user who can own records.
|
|
type Owner struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
|
|
// Owners lists active and archived owners.
|
|
func (c *Client) Owners(ctx context.Context) ([]Owner, error) {
|
|
var all []Owner
|
|
for _, archived := range []bool{false, true} {
|
|
after := ""
|
|
for page := 0; page < 50; page++ {
|
|
q := url.Values{"limit": {"100"}, "archived": {fmt.Sprint(archived)}}
|
|
if after != "" {
|
|
q.Set("after", after)
|
|
}
|
|
var out struct {
|
|
Results []Owner `json:"results"`
|
|
Paging paging `json:"paging"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/owners/?"+q.Encode(), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out.Results {
|
|
out.Results[i].Archived = archived
|
|
}
|
|
all = append(all, out.Results...)
|
|
if after = out.Paging.Next.After; after == "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
type paging struct {
|
|
Next struct {
|
|
After string `json:"after"`
|
|
} `json:"next"`
|
|
}
|
|
|
|
// Pipeline is a deal pipeline with its stages.
|
|
type Pipeline struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
DisplayOrder int `json:"displayOrder"`
|
|
Archived bool `json:"archived"`
|
|
Stages []Stage `json:"stages"`
|
|
}
|
|
|
|
// Stage is one pipeline stage. Metadata carries isClosed and probability as strings.
|
|
type Stage struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
DisplayOrder int `json:"displayOrder"`
|
|
Archived bool `json:"archived"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
// DealPipelines lists the portal's deal pipelines.
|
|
func (c *Client) DealPipelines(ctx context.Context) ([]Pipeline, error) {
|
|
var out struct {
|
|
Results []Pipeline `json:"results"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/pipelines/deals", nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Results, nil
|
|
}
|
|
|
|
// Property is a CRM property definition.
|
|
type Property struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
FieldType string `json:"fieldType"`
|
|
GroupName string `json:"groupName"`
|
|
Options []PropertyOption `json:"options"`
|
|
Hidden bool `json:"hidden"`
|
|
Calculated bool `json:"calculated"`
|
|
ModificationMetadata struct {
|
|
ReadOnlyValue bool `json:"readOnlyValue"`
|
|
} `json:"modificationMetadata"`
|
|
}
|
|
|
|
// PropertyOption is one enumeration value.
|
|
type PropertyOption struct {
|
|
Label string `json:"label"`
|
|
Value string `json:"value"`
|
|
DisplayOrder int `json:"displayOrder"`
|
|
Hidden bool `json:"hidden"`
|
|
}
|
|
|
|
// Properties lists an object type's properties.
|
|
func (c *Client) Properties(ctx context.Context, objectType string) ([]Property, error) {
|
|
var out struct {
|
|
Results []Property `json:"results"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/properties/"+objectType, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Results, nil
|
|
}
|
|
|
|
// PropertyDef is a property Warmbly creates.
|
|
type PropertyDef struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
FieldType string `json:"fieldType"`
|
|
GroupName string `json:"groupName"`
|
|
Description string `json:"description,omitempty"`
|
|
Options []PropertyOption `json:"options,omitempty"`
|
|
}
|
|
|
|
// EnsureContactProperties creates the property group and any missing property.
|
|
// Existing ones are left as the portal's admins may have tuned them.
|
|
func (c *Client) EnsureContactProperties(ctx context.Context, group, groupLabel string, defs []PropertyDef) error {
|
|
err := c.do(ctx, http.MethodPost, "/crm/v3/properties/contacts/groups",
|
|
map[string]any{"name": group, "label": groupLabel, "displayOrder": -1}, nil)
|
|
if ae, ok := AsAPIError(err); err != nil && !(ok && ae.Conflict()) {
|
|
return err
|
|
}
|
|
existing, err := c.Properties(ctx, "contacts")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
have := map[string]bool{}
|
|
for _, p := range existing {
|
|
have[p.Name] = true
|
|
}
|
|
var missing []PropertyDef
|
|
for _, d := range defs {
|
|
if !have[d.Name] {
|
|
missing = append(missing, d)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
err = c.do(ctx, http.MethodPost, "/crm/v3/properties/contacts/batch/create", map[string]any{"inputs": missing}, nil)
|
|
if ae, ok := AsAPIError(err); err != nil && !(ok && ae.Conflict()) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ContactUpsert is one contact written by email.
|
|
type ContactUpsert struct {
|
|
Email string
|
|
Properties map[string]string
|
|
}
|
|
|
|
// UpsertContacts creates or updates contacts keyed by email, at most 100 per call.
|
|
// The result maps lowercased email to record id.
|
|
func (c *Client) UpsertContacts(ctx context.Context, in []ContactUpsert) (map[string]string, error) {
|
|
out := map[string]string{}
|
|
for start := 0; start < len(in); start += 100 {
|
|
end := min(start+100, len(in))
|
|
inputs := make([]map[string]any, 0, end-start)
|
|
for _, ci := range in[start:end] {
|
|
props := map[string]string{}
|
|
for k, v := range ci.Properties {
|
|
props[k] = v
|
|
}
|
|
props["email"] = ci.Email
|
|
inputs = append(inputs, map[string]any{"idProperty": "email", "id": ci.Email, "properties": props})
|
|
}
|
|
var resp struct {
|
|
Results []Object `json:"results"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/contacts/batch/upsert", map[string]any{"inputs": inputs}, &resp); err != nil {
|
|
return out, err
|
|
}
|
|
for _, r := range resp.Results {
|
|
out[strings.ToLower(r.Prop("email"))] = r.ID
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetObject reads one record with properties and associations.
|
|
func (c *Client) GetObject(ctx context.Context, objectType, id string, props, assocs []string) (*Object, error) {
|
|
q := url.Values{}
|
|
if len(props) > 0 {
|
|
q.Set("properties", strings.Join(props, ","))
|
|
}
|
|
if len(assocs) > 0 {
|
|
q.Set("associations", strings.Join(assocs, ","))
|
|
}
|
|
var out Object
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/objects/"+objectType+"/"+url.PathEscape(id)+"?"+q.Encode(), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ReadObjects reads records by id, 100 per call.
|
|
func (c *Client) ReadObjects(ctx context.Context, objectType string, ids, props []string) ([]Object, error) {
|
|
var all []Object
|
|
for start := 0; start < len(ids); start += 100 {
|
|
end := min(start+100, len(ids))
|
|
inputs := make([]map[string]string, 0, end-start)
|
|
for _, id := range ids[start:end] {
|
|
inputs = append(inputs, map[string]string{"id": id})
|
|
}
|
|
var out struct {
|
|
Results []Object `json:"results"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/"+objectType+"/batch/read",
|
|
map[string]any{"inputs": inputs, "properties": props}, &out); err != nil {
|
|
return all, err
|
|
}
|
|
all = append(all, out.Results...)
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// Filter is one search condition.
|
|
type Filter struct {
|
|
PropertyName string `json:"propertyName"`
|
|
Operator string `json:"operator"`
|
|
Value string `json:"value,omitempty"`
|
|
Values []string `json:"values,omitempty"`
|
|
}
|
|
|
|
// SearchPage is one page of search results.
|
|
type SearchPage struct {
|
|
Total int `json:"total"`
|
|
Results []Object `json:"results"`
|
|
Paging paging `json:"paging"`
|
|
}
|
|
|
|
// Search runs a CRM search: AND inside a group, OR across groups.
|
|
func (c *Client) Search(ctx context.Context, objectType string, groups [][]Filter, sortProp string, props []string, after string, limit int) (*SearchPage, error) {
|
|
fg := make([]map[string]any, 0, len(groups))
|
|
for _, g := range groups {
|
|
fg = append(fg, map[string]any{"filters": g})
|
|
}
|
|
body := map[string]any{"filterGroups": fg, "properties": props, "limit": limit}
|
|
if sortProp != "" {
|
|
body["sorts"] = []map[string]string{{"propertyName": sortProp, "direction": "ASCENDING"}}
|
|
}
|
|
if after != "" {
|
|
body["after"] = after
|
|
}
|
|
var out SearchPage
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/"+objectType+"/search", body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// Create writes a record with its associations.
|
|
func (c *Client) Create(ctx context.Context, objectType string, props map[string]string, assocs []Assoc) (*Object, error) {
|
|
body := map[string]any{"properties": props}
|
|
if a := assocBody(assocs); len(a) > 0 {
|
|
body["associations"] = a
|
|
}
|
|
var out Object
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/"+objectType, body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// Update patches a record's properties.
|
|
func (c *Client) Update(ctx context.Context, objectType, id string, props map[string]string) (*Object, error) {
|
|
var out Object
|
|
if err := c.do(ctx, http.MethodPatch, "/crm/v3/objects/"+objectType+"/"+url.PathEscape(id),
|
|
map[string]any{"properties": props}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// Archive deletes a record (HubSpot keeps it restorable for 90 days).
|
|
func (c *Client) Archive(ctx context.Context, objectType, id string) error {
|
|
err := c.do(ctx, http.MethodDelete, "/crm/v3/objects/"+objectType+"/"+url.PathEscape(id), nil, nil)
|
|
if ae, ok := AsAPIError(err); ok && ae.NotFound() {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ObjectUpdate is one row of a batch update.
|
|
type ObjectUpdate struct {
|
|
ID string
|
|
Properties map[string]string
|
|
}
|
|
|
|
// BatchUpdate patches up to 100 records per call.
|
|
func (c *Client) BatchUpdate(ctx context.Context, objectType string, in []ObjectUpdate) error {
|
|
for start := 0; start < len(in); start += 100 {
|
|
end := min(start+100, len(in))
|
|
inputs := make([]map[string]any, 0, end-start)
|
|
for _, u := range in[start:end] {
|
|
inputs = append(inputs, map[string]any{"id": u.ID, "properties": u.Properties})
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/"+objectType+"/batch/update", map[string]any{"inputs": inputs}, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BatchArchive deletes up to 100 records per call.
|
|
func (c *Client) BatchArchive(ctx context.Context, objectType string, ids []string) error {
|
|
for start := 0; start < len(ids); start += 100 {
|
|
end := min(start+100, len(ids))
|
|
inputs := make([]map[string]string, 0, end-start)
|
|
for _, id := range ids[start:end] {
|
|
inputs = append(inputs, map[string]string{"id": id})
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/objects/"+objectType+"/batch/archive", map[string]any{"inputs": inputs}, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AssociateDefault links two records with HubSpot's default association label.
|
|
func (c *Client) AssociateDefault(ctx context.Context, fromType, fromID, toType, toID string) error {
|
|
return c.do(ctx, http.MethodPut, fmt.Sprintf("/crm/v4/objects/%s/%s/associations/default/%s/%s",
|
|
fromType, url.PathEscape(fromID), toType, url.PathEscape(toID)), nil, nil)
|
|
}
|
|
|
|
// AssociatedIDs lists the ids of toType records associated with one record.
|
|
func (c *Client) AssociatedIDs(ctx context.Context, fromType, fromID, toType string, max int) ([]string, error) {
|
|
var ids []string
|
|
after := ""
|
|
for len(ids) < max {
|
|
q := url.Values{"limit": {"500"}}
|
|
if after != "" {
|
|
q.Set("after", after)
|
|
}
|
|
var out struct {
|
|
Results []struct {
|
|
ToObjectID any `json:"toObjectId"`
|
|
} `json:"results"`
|
|
Paging paging `json:"paging"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/crm/v4/objects/%s/%s/associations/%s?%s",
|
|
fromType, url.PathEscape(fromID), toType, q.Encode()), nil, &out); err != nil {
|
|
return ids, err
|
|
}
|
|
for _, r := range out.Results {
|
|
ids = append(ids, fmt.Sprint(r.ToObjectID))
|
|
}
|
|
if after = out.Paging.Next.After; after == "" {
|
|
break
|
|
}
|
|
}
|
|
if len(ids) > max {
|
|
ids = ids[:max]
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// BatchAssociations maps each from id to its associated toType ids.
|
|
func (c *Client) BatchAssociations(ctx context.Context, fromType, toType string, ids []string) (map[string][]string, error) {
|
|
out := map[string][]string{}
|
|
for start := 0; start < len(ids); start += 100 {
|
|
end := min(start+100, len(ids))
|
|
inputs := make([]map[string]string, 0, end-start)
|
|
for _, id := range ids[start:end] {
|
|
inputs = append(inputs, map[string]string{"id": id})
|
|
}
|
|
var resp struct {
|
|
Results []struct {
|
|
From struct {
|
|
ID string `json:"id"`
|
|
} `json:"from"`
|
|
To []struct {
|
|
ToObjectID any `json:"toObjectId"`
|
|
} `json:"to"`
|
|
} `json:"results"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/crm/v4/associations/%s/%s/batch/read", fromType, toType),
|
|
map[string]any{"inputs": inputs}, &resp); err != nil {
|
|
return out, err
|
|
}
|
|
for _, r := range resp.Results {
|
|
for _, t := range r.To {
|
|
out[r.From.ID] = append(out[r.From.ID], fmt.Sprint(t.ToObjectID))
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// List is a HubSpot contact list (segment).
|
|
type List struct {
|
|
ListID string `json:"listId"`
|
|
Name string `json:"name"`
|
|
ProcessingType string `json:"processingType"`
|
|
ObjectTypeID string `json:"objectTypeId"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
Additional struct {
|
|
Size string `json:"hs_list_size"`
|
|
} `json:"additionalProperties"`
|
|
}
|
|
|
|
// SearchLists finds contact lists by name.
|
|
func (c *Client) SearchLists(ctx context.Context, query string, offset, count int) ([]List, bool, int, error) {
|
|
body := map[string]any{
|
|
"query": query,
|
|
"offset": offset,
|
|
"count": count,
|
|
"additionalProperties": []string{"hs_list_size"},
|
|
"processingTypes": []string{"MANUAL", "DYNAMIC", "SNAPSHOT"},
|
|
"sort": "HS_UPDATED_AT",
|
|
"listIds": []string{},
|
|
"objectTypeId": "0-1",
|
|
"includeFilters": false,
|
|
"includeAdditionalFields": true,
|
|
}
|
|
var out struct {
|
|
Lists []List `json:"lists"`
|
|
HasMore bool `json:"hasMore"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/crm/v3/lists/search", body, &out); err != nil {
|
|
return nil, false, 0, err
|
|
}
|
|
var contacts []List
|
|
for _, l := range out.Lists {
|
|
if l.ObjectTypeID == "" || l.ObjectTypeID == "0-1" {
|
|
contacts = append(contacts, l)
|
|
}
|
|
}
|
|
return contacts, out.HasMore, out.Offset, nil
|
|
}
|
|
|
|
// GetList reads one list.
|
|
func (c *Client) GetList(ctx context.Context, listID string) (*List, error) {
|
|
var out struct {
|
|
List List `json:"list"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/lists/"+url.PathEscape(listID)+"?includeFilters=false", nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out.List, nil
|
|
}
|
|
|
|
// ListMemberIDs returns up to max record ids in a list and whether it had more.
|
|
func (c *Client) ListMemberIDs(ctx context.Context, listID string, max int) ([]string, bool, error) {
|
|
var ids []string
|
|
after := ""
|
|
for {
|
|
q := url.Values{"limit": {"250"}}
|
|
if after != "" {
|
|
q.Set("after", after)
|
|
}
|
|
var out struct {
|
|
Results []struct {
|
|
RecordID string `json:"recordId"`
|
|
} `json:"results"`
|
|
Paging paging `json:"paging"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/crm/v3/lists/"+url.PathEscape(listID)+"/memberships?"+q.Encode(), nil, &out); err != nil {
|
|
return ids, false, err
|
|
}
|
|
for i, r := range out.Results {
|
|
ids = append(ids, r.RecordID)
|
|
if len(ids) >= max {
|
|
return ids, i < len(out.Results)-1 || out.Paging.Next.After != "", nil
|
|
}
|
|
}
|
|
if after = out.Paging.Next.After; after == "" {
|
|
return ids, false, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// FindCompanyByDomain returns the first company on a domain, or nil.
|
|
func (c *Client) FindCompanyByDomain(ctx context.Context, domain string) (*Object, error) {
|
|
page, err := c.Search(ctx, "companies", [][]Filter{{{PropertyName: "domain", Operator: "EQ", Value: domain}}},
|
|
"", []string{"name", "domain"}, "", 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(page.Results) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &page.Results[0], nil
|
|
}
|