mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 16:02:13 +00:00
837 lines
25 KiB
Go
837 lines
25 KiB
Go
package hubspot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Pull keeps the mirror current: owners and pipelines on a slow beat, deals,
|
|
// tasks and contacts incrementally by last-modified time. Webhooks only make a
|
|
// record's next pull come sooner, so a missed delivery is never a missed change.
|
|
|
|
const (
|
|
pullInterval = time.Minute
|
|
metaRefreshEvery = 30 * time.Minute
|
|
pagesPerPull = 20
|
|
contactPagesTick = 10
|
|
cursorDeals = "deals"
|
|
cursorTasks = "tasks"
|
|
cursorContacts = "contacts"
|
|
holdSourceCRM = "crm"
|
|
initialTaskWindow = 90 * 24 * time.Hour
|
|
)
|
|
|
|
// RunPuller pulls every HubSpot workspace once a minute until ctx ends. Each
|
|
// workspace is claimed in Redis, so several consumers share the work.
|
|
func (s *Service) RunPuller(ctx context.Context) {
|
|
t := time.NewTicker(pullInterval)
|
|
defer t.Stop()
|
|
for {
|
|
s.pullAll(ctx)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) pullAll(ctx context.Context) {
|
|
rows, err := s.d.Repo.ListProviderOrgs(ctx, provider)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("hubspot: list workspaces")
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if !s.claim(ctx, "pull:"+row.OrganizationID.String(), 4*time.Minute) {
|
|
continue
|
|
}
|
|
o, err := s.resolve(ctx, row.OrganizationID)
|
|
if err != nil || o == nil {
|
|
s.release(ctx, "pull:"+row.OrganizationID.String())
|
|
continue
|
|
}
|
|
pctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Str("org_id", o.ID.String()).Msg("hubspot: pull panicked")
|
|
}
|
|
}()
|
|
s.pullOrg(pctx, o, false)
|
|
}()
|
|
cancel()
|
|
s.release(ctx, "pull:"+row.OrganizationID.String())
|
|
}
|
|
}
|
|
|
|
func (s *Service) claim(ctx context.Context, key string, ttl time.Duration) bool {
|
|
if s.d.Cache == nil {
|
|
return true
|
|
}
|
|
ok, err := s.d.Cache.SetNX(ctx, "hubspot:lock:"+key, 1, ttl).Result()
|
|
return err != nil || ok
|
|
}
|
|
|
|
func (s *Service) release(ctx context.Context, key string) {
|
|
if s.d.Cache != nil {
|
|
s.d.Cache.Del(ctx, "hubspot:lock:"+key)
|
|
}
|
|
}
|
|
|
|
// due reports whether a slow refresh is due, and claims it.
|
|
func (s *Service) due(ctx context.Context, key string, every time.Duration, force bool) bool {
|
|
if force || s.d.Cache == nil {
|
|
return true
|
|
}
|
|
ok, err := s.d.Cache.SetNX(ctx, "hubspot:due:"+key, 1, every).Result()
|
|
return err != nil || ok
|
|
}
|
|
|
|
func (s *Service) pullOrg(ctx context.Context, o *org, full bool) {
|
|
key := o.ID.String()
|
|
if s.due(ctx, "owners:"+key, metaRefreshEvery, full) {
|
|
s.recordRun(ctx, o, "owners", nil, s.pullOwners(ctx, o))
|
|
}
|
|
if s.due(ctx, "pipelines:"+key, metaRefreshEvery, full) {
|
|
s.recordRun(ctx, o, "pipelines", nil, s.pullPipelines(ctx, o))
|
|
}
|
|
if o.Config.WriteProperties && s.due(ctx, "props:"+key, 6*time.Hour, full) {
|
|
_ = s.ensureProperties(ctx, o)
|
|
}
|
|
s.pullDeals(ctx, o)
|
|
s.pullTasks(ctx, o)
|
|
s.pullContacts(ctx, o)
|
|
}
|
|
|
|
func (s *Service) recordRun(ctx context.Context, o *org, objectType string, at *time.Time, err error) {
|
|
msg := ""
|
|
if err != nil {
|
|
msg = s.userError(ctx, o, err).Message
|
|
}
|
|
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, objectType, at, msg)
|
|
}
|
|
|
|
func (s *Service) pullOwners(ctx context.Context, o *org) error {
|
|
owners, err := o.Client.Owners(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := make([]models.CRMOwner, 0, len(owners))
|
|
for _, ow := range owners {
|
|
out = append(out, models.CRMOwner{ExternalID: ow.ID, Email: ow.Email, FirstName: ow.FirstName, LastName: ow.LastName, Archived: ow.Archived})
|
|
}
|
|
if err := s.d.Repo.ReplaceOwners(ctx, o.ID, provider, out); err != nil {
|
|
return err
|
|
}
|
|
s.notify(ctx, o.ID, "", "owner")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) pullPipelines(ctx context.Context, o *org) error {
|
|
pipes, err := o.Client.DealPipelines(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var keep []string
|
|
for _, p := range pipes {
|
|
if p.Archived || (len(o.Config.DealPipelines) > 0 && !slices.Contains(o.Config.DealPipelines, p.ID)) {
|
|
continue
|
|
}
|
|
localID, err := s.d.Repo.MirrorPipeline(ctx, o.ID, provider, p.ID, p.Label, p.DisplayOrder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keep = append(keep, p.ID)
|
|
var keepStages []string
|
|
stages := append([]Stage(nil), p.Stages...)
|
|
slices.SortStableFunc(stages, func(a, b Stage) int { return a.DisplayOrder - b.DisplayOrder })
|
|
for i, st := range stages {
|
|
if st.Archived {
|
|
continue
|
|
}
|
|
meta := stageMetaFrom(st)
|
|
if _, err := s.d.Repo.MirrorStage(ctx, o.ID, localID, provider, st.ID, st.Label, stageColor(i, meta), i, meta.toMap()); err != nil {
|
|
return err
|
|
}
|
|
keepStages = append(keepStages, st.ID)
|
|
}
|
|
if err := s.d.Repo.PruneStages(ctx, o.ID, localID, provider, keepStages); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.d.Repo.PrunePipelines(ctx, o.ID, provider, keep); err != nil {
|
|
return err
|
|
}
|
|
s.notify(ctx, o.ID, "", "pipeline")
|
|
return nil
|
|
}
|
|
|
|
// modifiedFilter is "changed at or after the cursor", in epoch milliseconds.
|
|
func modifiedFilter(prop string, cursor *time.Time) []Filter {
|
|
if cursor == nil {
|
|
return nil
|
|
}
|
|
return []Filter{{PropertyName: prop, Operator: "GTE", Value: strconv.FormatInt(cursor.UnixMilli(), 10)}}
|
|
}
|
|
|
|
// advance moves a cursor past a page. A full page that did not move it (more
|
|
// than a page of records modified in the same millisecond) steps one ms on,
|
|
// so the pull cannot spin on the same page.
|
|
func advance(cursor *time.Time, last *time.Time, fullPage bool) *time.Time {
|
|
if last == nil {
|
|
return cursor
|
|
}
|
|
if cursor != nil && !last.After(*cursor) && fullPage {
|
|
next := cursor.Add(time.Millisecond)
|
|
return &next
|
|
}
|
|
return last
|
|
}
|
|
|
|
func (s *Service) pullDeals(ctx context.Context, o *org) {
|
|
cursor, err := s.d.Repo.GetCursor(ctx, o.ID, provider, cursorDeals)
|
|
if err != nil {
|
|
return
|
|
}
|
|
first := cursor == nil
|
|
for page := 0; page < pagesPerPull; page++ {
|
|
filters := modifiedFilter("hs_lastmodifieddate", cursor)
|
|
if len(o.Config.DealPipelines) > 0 {
|
|
filters = append(filters, Filter{PropertyName: "pipeline", Operator: "IN", Values: o.Config.DealPipelines})
|
|
}
|
|
var groups [][]Filter
|
|
if len(filters) > 0 {
|
|
groups = [][]Filter{filters}
|
|
}
|
|
res, err := o.Client.Search(ctx, "deals", groups, "hs_lastmodifieddate", dealProps, "", 100)
|
|
if err != nil {
|
|
s.recordRun(ctx, o, cursorDeals, nil, err)
|
|
return
|
|
}
|
|
if len(res.Results) == 0 {
|
|
break
|
|
}
|
|
last, err := s.mirrorDeals(ctx, o, res.Results, !first)
|
|
if err != nil {
|
|
s.recordRun(ctx, o, cursorDeals, nil, err)
|
|
return
|
|
}
|
|
cursor = advance(cursor, last, len(res.Results) == 100)
|
|
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, cursorDeals, cursor, "")
|
|
s.notify(ctx, o.ID, "", "deal")
|
|
if len(res.Results) < 100 {
|
|
break
|
|
}
|
|
}
|
|
s.recordRun(ctx, o, cursorDeals, cursor, nil)
|
|
}
|
|
|
|
// mirrorDeals writes a page of deals and returns the latest modified time.
|
|
// applyExit is false on the very first pull, so deals that existed before the
|
|
// workspace switched to HubSpot do not stop anyone's campaigns.
|
|
func (s *Service) mirrorDeals(ctx context.Context, o *org, deals []Object, applyExit bool) (*time.Time, error) {
|
|
ids := make([]string, 0, len(deals))
|
|
for _, d := range deals {
|
|
ids = append(ids, d.ID)
|
|
}
|
|
assoc, err := o.Client.BatchAssociations(ctx, "deals", "contacts", ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var contactExt []string
|
|
for _, cs := range assoc {
|
|
contactExt = append(contactExt, cs...)
|
|
}
|
|
local, err := s.localContacts(ctx, o, contactExt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var last *time.Time
|
|
pipelinesRefreshed := false
|
|
for _, d := range deals {
|
|
mod := parseHSTime(d.Prop("hs_lastmodifieddate"))
|
|
if mod != nil && (last == nil || mod.After(*last)) {
|
|
last = mod
|
|
}
|
|
var contactID *uuid.UUID
|
|
for _, ext := range assoc[d.ID] {
|
|
if id, ok := local[ext]; ok {
|
|
contactID = &id
|
|
break
|
|
}
|
|
}
|
|
created, mirrored, err := s.mirrorDeal(ctx, o, &d, contactID)
|
|
if err != nil {
|
|
return last, err
|
|
}
|
|
if !mirrored && !pipelinesRefreshed {
|
|
pipelinesRefreshed = true
|
|
if err := s.pullPipelines(ctx, o); err == nil {
|
|
if created, mirrored, err = s.mirrorDeal(ctx, o, &d, contactID); err != nil {
|
|
return last, err
|
|
}
|
|
}
|
|
}
|
|
if applyExit && created && mirrored && contactID != nil && o.Config.ExitRules.DealCreated && recentlyCreated(&d) {
|
|
s.holdContact(ctx, o, *contactID, "A deal was opened in HubSpot: "+d.Prop("dealname"))
|
|
}
|
|
}
|
|
return last, nil
|
|
}
|
|
|
|
// recentlyCreated keeps the deal exit rule to deals opened now, not history a
|
|
// pull happens to meet for the first time.
|
|
func recentlyCreated(d *Object) bool {
|
|
created := parseHSTime(d.Prop("createdate"))
|
|
return created != nil && time.Since(*created) < 48*time.Hour
|
|
}
|
|
|
|
// mirrorDeal writes one deal. mirrored is false when its stage is not one
|
|
// Warmbly knows (a pipeline the workspace chose not to mirror).
|
|
func (s *Service) mirrorDeal(ctx context.Context, o *org, d *Object, contactID *uuid.UUID) (created, mirrored bool, err error) {
|
|
stage, err := s.d.Repo.GetLinkByExternal(ctx, o.ID, provider, models.CRMObjectStage, d.Prop("dealstage"))
|
|
if err != nil || stage == nil {
|
|
return false, false, err
|
|
}
|
|
pipe, err := s.d.Repo.GetLinkByExternal(ctx, o.ID, provider, models.CRMObjectPipeline, d.Prop("pipeline"))
|
|
if err != nil || pipe == nil {
|
|
return false, false, err
|
|
}
|
|
meta := stageMetaFromMap(stage.Meta)
|
|
in := &repository.MirrorDeal{
|
|
OrganizationID: o.ID,
|
|
Provider: provider,
|
|
ExternalID: d.ID,
|
|
PipelineID: pipe.LocalID,
|
|
StageID: stage.LocalID,
|
|
ContactID: contactID,
|
|
Name: firstNonEmpty(d.Prop("dealname"), "Untitled deal"),
|
|
Currency: d.Prop("deal_currency_code"),
|
|
Status: meta.status(),
|
|
CreatedAt: parseHSTime(d.Prop("createdate")),
|
|
Meta: ownerMeta(d.Prop("hubspot_owner_id")),
|
|
}
|
|
// numeric(12,2) holds under ten billion; a larger amount is left blank
|
|
// rather than failing the page it arrived on.
|
|
if v, perr := strconv.ParseFloat(d.Prop("amount"), 64); perr == nil && v > -1e10 && v < 1e10 {
|
|
in.Value = &v
|
|
}
|
|
if cd := parseHSTime(d.Prop("closedate")); cd != nil {
|
|
in.CloseDate = cd
|
|
if meta.Closed {
|
|
in.ClosedAt = cd
|
|
}
|
|
}
|
|
if in.AssignedTo, err = s.d.Repo.UserForOwner(ctx, o.ID, provider, d.Prop("hubspot_owner_id")); err != nil {
|
|
return false, false, err
|
|
}
|
|
_, created, err = s.d.Repo.MirrorDeal(ctx, in)
|
|
return created, err == nil, err
|
|
}
|
|
|
|
// localContacts maps HubSpot contact ids to Warmbly contacts, linking any
|
|
// unlinked ones that match by email.
|
|
func (s *Service) localContacts(ctx context.Context, o *org, ext []string) (map[string]uuid.UUID, error) {
|
|
out := map[string]uuid.UUID{}
|
|
var unknown []string
|
|
seen := map[string]bool{}
|
|
for _, id := range ext {
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
rec, err := s.d.Repo.GetContactRecordByExternal(ctx, o.ID, provider, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec != nil {
|
|
out[id] = rec.ContactID
|
|
} else {
|
|
unknown = append(unknown, id)
|
|
}
|
|
}
|
|
if len(unknown) == 0 {
|
|
return out, nil
|
|
}
|
|
objs, err := o.Client.ReadObjects(ctx, "contacts", unknown, s.readProps(o))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
emails := make([]string, 0, len(objs))
|
|
for _, ob := range objs {
|
|
emails = append(emails, ob.Prop("email"))
|
|
}
|
|
byEmail, err := s.d.Repo.ContactIDsByEmail(ctx, o.ID, emails)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range objs {
|
|
id, ok := byEmail[strings.ToLower(strings.TrimSpace(objs[i].Prop("email")))]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, _, err := s.storeContactRecord(ctx, o, id, &objs[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
out[objs[i].ID] = id
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// pullTasks mirrors the tasks owned by HubSpot users who are workspace members:
|
|
// each member's HubSpot task list appears on their Warmbly Tasks page.
|
|
func (s *Service) pullTasks(ctx context.Context, o *org) {
|
|
owners, err := s.d.Repo.ListOwners(ctx, o.ID, provider)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var mapped []string
|
|
for _, ow := range owners {
|
|
if ow.UserID != nil && !ow.Archived {
|
|
mapped = append(mapped, ow.ExternalID)
|
|
}
|
|
}
|
|
if len(mapped) == 0 {
|
|
return
|
|
}
|
|
cursor, err := s.d.Repo.GetCursor(ctx, o.ID, provider, cursorTasks)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for page := 0; page < pagesPerPull; page++ {
|
|
filters := []Filter{{PropertyName: "hubspot_owner_id", Operator: "IN", Values: mapped}}
|
|
if cursor != nil {
|
|
filters = append(filters, modifiedFilter("hs_lastmodifieddate", cursor)...)
|
|
} else {
|
|
since := time.Now().Add(-initialTaskWindow)
|
|
filters = append(filters, Filter{PropertyName: "hs_lastmodifieddate", Operator: "GTE", Value: strconv.FormatInt(since.UnixMilli(), 10)})
|
|
}
|
|
res, err := o.Client.Search(ctx, "tasks", [][]Filter{filters}, "hs_lastmodifieddate", taskProps, "", 100)
|
|
if err != nil {
|
|
s.recordRun(ctx, o, cursorTasks, nil, err)
|
|
return
|
|
}
|
|
if len(res.Results) == 0 {
|
|
if cursor == nil {
|
|
now := time.Now()
|
|
cursor = &now
|
|
}
|
|
break
|
|
}
|
|
last, err := s.mirrorTasks(ctx, o, res.Results)
|
|
if err != nil {
|
|
s.recordRun(ctx, o, cursorTasks, nil, err)
|
|
return
|
|
}
|
|
cursor = advance(cursor, last, len(res.Results) == 100)
|
|
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, cursorTasks, cursor, "")
|
|
s.notify(ctx, o.ID, "", "task")
|
|
if len(res.Results) < 100 {
|
|
break
|
|
}
|
|
}
|
|
s.recordRun(ctx, o, cursorTasks, cursor, nil)
|
|
}
|
|
|
|
func (s *Service) mirrorTasks(ctx context.Context, o *org, tasks []Object) (*time.Time, error) {
|
|
ids := make([]string, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
ids = append(ids, t.ID)
|
|
}
|
|
contactAssoc, err := o.Client.BatchAssociations(ctx, "tasks", "contacts", ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dealAssoc, err := o.Client.BatchAssociations(ctx, "tasks", "deals", ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var contactExt []string
|
|
for _, cs := range contactAssoc {
|
|
contactExt = append(contactExt, cs...)
|
|
}
|
|
local, err := s.localContacts(ctx, o, contactExt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fallback, err := s.d.Repo.FallbackActor(ctx, o.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var last *time.Time
|
|
for i := range tasks {
|
|
t := &tasks[i]
|
|
if mod := parseHSTime(t.Prop("hs_lastmodifieddate")); mod != nil && (last == nil || mod.After(*last)) {
|
|
last = mod
|
|
}
|
|
var contactID, dealID *uuid.UUID
|
|
for _, ext := range contactAssoc[t.ID] {
|
|
if id, ok := local[ext]; ok {
|
|
contactID = &id
|
|
break
|
|
}
|
|
}
|
|
for _, ext := range dealAssoc[t.ID] {
|
|
if l, _ := s.d.Repo.GetLinkByExternal(ctx, o.ID, provider, models.CRMObjectDeal, ext); l != nil {
|
|
id := l.LocalID
|
|
dealID = &id
|
|
break
|
|
}
|
|
}
|
|
if err := s.mirrorTask(ctx, o, t, contactID, dealID, fallback); err != nil {
|
|
return last, err
|
|
}
|
|
}
|
|
return last, nil
|
|
}
|
|
|
|
func (s *Service) mirrorTask(ctx context.Context, o *org, t *Object, contactID, dealID *uuid.UUID, fallback uuid.UUID) error {
|
|
owner := t.Prop("hubspot_owner_id")
|
|
assigned, err := s.d.Repo.UserForOwner(ctx, o.ID, provider, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
createdBy := fallback
|
|
if assigned != nil {
|
|
createdBy = *assigned
|
|
}
|
|
in := &repository.MirrorTask{
|
|
OrganizationID: o.ID,
|
|
Provider: provider,
|
|
ExternalID: t.ID,
|
|
ContactID: contactID,
|
|
DealID: dealID,
|
|
AssignedTo: assigned,
|
|
CreatedBy: createdBy,
|
|
Title: firstNonEmpty(t.Prop("hs_task_subject"), "Untitled task"),
|
|
DueDate: parseHSTime(t.Prop("hs_timestamp")),
|
|
Priority: localTaskPriority(t.Prop("hs_task_priority")),
|
|
Type: localTaskType(t.Prop("hs_task_type")),
|
|
Status: localTaskStatus(t.Prop("hs_task_status")),
|
|
CreatedAt: parseHSTime(t.Prop("hs_createdate")),
|
|
Meta: ownerMeta(owner),
|
|
}
|
|
if body := htmlToText(t.Prop("hs_task_body")); body != "" {
|
|
in.Description = &body
|
|
}
|
|
if in.Status == models.CRMTaskStatusCompleted {
|
|
in.CompletedAt = firstTime(parseHSTime(t.Prop("hs_task_completion_date")), parseHSTime(t.Prop("hs_lastmodifieddate")))
|
|
}
|
|
_, _, err = s.d.Repo.MirrorTask(ctx, in)
|
|
return err
|
|
}
|
|
|
|
func firstTime(ts ...*time.Time) *time.Time {
|
|
for _, t := range ts {
|
|
if t != nil {
|
|
return t
|
|
}
|
|
}
|
|
now := time.Now()
|
|
return &now
|
|
}
|
|
|
|
// pullContacts reads contacts changed in HubSpot and updates the ones Warmbly
|
|
// has: owner, lifecycle stage, lead status, opt-out and mapped fields.
|
|
func (s *Service) pullContacts(ctx context.Context, o *org) {
|
|
cursor, err := s.d.Repo.GetCursor(ctx, o.ID, provider, cursorContacts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if cursor == nil {
|
|
// Nothing to catch up on: contacts link as Warmbly meets them.
|
|
now := time.Now()
|
|
s.recordRun(ctx, o, cursorContacts, &now, nil)
|
|
return
|
|
}
|
|
touched := false
|
|
for page := 0; page < contactPagesTick; page++ {
|
|
res, err := o.Client.Search(ctx, "contacts", [][]Filter{modifiedFilter("lastmodifieddate", cursor)}, "lastmodifieddate", s.readProps(o), "", 100)
|
|
if err != nil {
|
|
s.recordRun(ctx, o, cursorContacts, nil, err)
|
|
return
|
|
}
|
|
if len(res.Results) == 0 {
|
|
break
|
|
}
|
|
var last *time.Time
|
|
emails := make([]string, 0, len(res.Results))
|
|
for _, c := range res.Results {
|
|
emails = append(emails, c.Prop("email"))
|
|
}
|
|
byEmail, err := s.d.Repo.ContactIDsByEmail(ctx, o.ID, emails)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for i := range res.Results {
|
|
c := &res.Results[i]
|
|
if mod := parseHSTime(c.Prop("lastmodifieddate")); mod != nil && (last == nil || mod.After(*last)) {
|
|
last = mod
|
|
}
|
|
var contactID uuid.UUID
|
|
if rec, _ := s.d.Repo.GetContactRecordByExternal(ctx, o.ID, provider, c.ID); rec != nil {
|
|
contactID = rec.ContactID
|
|
} else if id, ok := byEmail[strings.ToLower(strings.TrimSpace(c.Prop("email")))]; ok {
|
|
contactID = id
|
|
} else {
|
|
continue
|
|
}
|
|
if err := s.applyContact(ctx, o, contactID, c); err != nil {
|
|
s.recordRun(ctx, o, cursorContacts, nil, err)
|
|
return
|
|
}
|
|
touched = true
|
|
}
|
|
cursor = advance(cursor, last, len(res.Results) == 100)
|
|
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, cursorContacts, cursor, "")
|
|
if len(res.Results) < 100 {
|
|
break
|
|
}
|
|
}
|
|
if touched {
|
|
s.notify(ctx, o.ID, "", "contact")
|
|
}
|
|
s.recordRun(ctx, o, cursorContacts, cursor, nil)
|
|
}
|
|
|
|
// applyContact stores a pulled contact, copies the fields HubSpot owns onto
|
|
// the Warmbly contact, and runs the exit rules on what changed.
|
|
func (s *Service) applyContact(ctx context.Context, o *org, contactID uuid.UUID, c *Object) error {
|
|
rec, prev, err := s.storeContactRecord(ctx, o, contactID, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fields := map[string]string{}
|
|
custom := map[string]string{}
|
|
for field, prop := range o.Config.FieldMap {
|
|
dir := o.Config.FieldDirection[field]
|
|
if dir != models.CRMFieldPull && dir != models.CRMFieldBoth {
|
|
continue
|
|
}
|
|
v := c.Prop(prop)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(field, "custom:") {
|
|
custom[strings.TrimPrefix(field, "custom:")] = v
|
|
} else if field != "email" {
|
|
fields[field] = v
|
|
}
|
|
}
|
|
if err := s.d.Repo.UpdateContactFields(ctx, o.ID, contactID, fields, custom); err != nil {
|
|
return err
|
|
}
|
|
rules := o.Config.ExitRules
|
|
if prev != nil && rec.LifecycleStage != prev.LifecycleStage && slices.Contains(rules.LifecycleStages, rec.LifecycleStage) {
|
|
label := s.optionLabel(ctx, o, "lifecyclestage", rec.LifecycleStage)
|
|
s.holdContact(ctx, o, contactID, "Lifecycle stage became "+label+" in HubSpot")
|
|
}
|
|
if rec.OptedOut && (prev == nil || !prev.OptedOut) && rules.OptedOut {
|
|
s.suppressOptOut(ctx, o, contactID, c.Prop("email"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// holdContact parks the contact in every campaign, like a person's pause.
|
|
func (s *Service) holdContact(ctx context.Context, o *org, contactID uuid.UUID, reason string) {
|
|
if s.d.Holds == nil {
|
|
return
|
|
}
|
|
held, err := s.d.Holds.HoldLeadEverywhere(ctx, contactID, nil, reason, holdSourceCRM)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("contact_id", contactID.String()).Msg("hubspot: exit rule could not hold the contact")
|
|
return
|
|
}
|
|
if len(held) > 0 {
|
|
s.notify(ctx, o.ID, contactID.String(), "contact")
|
|
}
|
|
}
|
|
|
|
func (s *Service) suppressOptOut(ctx context.Context, o *org, contactID uuid.UUID, email string) {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if s.d.Suppress == nil || email == "" {
|
|
return
|
|
}
|
|
if err := s.d.Suppress.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
|
|
OrganizationID: o.ID,
|
|
Email: email,
|
|
Kind: models.SuppressionKindEmail,
|
|
Reason: "Opted out of email in HubSpot",
|
|
Source: models.DeliverabilityEventUnsubscribe,
|
|
Metadata: map[string]interface{}{"provider": string(provider), "contact_id": contactID.String()},
|
|
}); err != nil {
|
|
log.Warn().Err(err).Msg("hubspot: could not suppress an opted-out contact")
|
|
}
|
|
}
|
|
|
|
// refreshContact pulls one contact with its deals, tasks and notes.
|
|
func (s *Service) refreshContact(ctx context.Context, o *org, contactID uuid.UUID, ext string) error {
|
|
obj, err := o.Client.GetObject(ctx, "contacts", ext, s.readProps(o), []string{"deals", "tasks", "notes"})
|
|
if err != nil {
|
|
if ae, ok := AsAPIError(err); ok && ae.NotFound() {
|
|
// Deleted or merged away in HubSpot: the contact is no longer linked.
|
|
return s.d.Repo.DeleteContactRecord(ctx, o.ID, provider, ext)
|
|
}
|
|
return err
|
|
}
|
|
if err := s.applyContact(ctx, o, contactID, obj); err != nil {
|
|
return err
|
|
}
|
|
if ids := obj.Associated("deals"); len(ids) > 0 {
|
|
deals, err := o.Client.ReadObjects(ctx, "deals", ids, dealProps)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range deals {
|
|
if _, _, err := s.mirrorDeal(ctx, o, &deals[i], &contactID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
fallback, err := s.d.Repo.FallbackActor(ctx, o.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ids := obj.Associated("tasks"); len(ids) > 0 {
|
|
tasks, err := o.Client.ReadObjects(ctx, "tasks", ids, taskProps)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range tasks {
|
|
if err := s.mirrorTask(ctx, o, &tasks[i], &contactID, nil, fallback); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if ids := obj.Associated("notes"); len(ids) > 0 {
|
|
notes, err := o.Client.ReadObjects(ctx, "notes", ids, []string{"hs_note_body", "hs_timestamp", "hubspot_owner_id", "hs_createdate"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range notes {
|
|
n := ¬es[i]
|
|
author := fallback
|
|
if u, _ := s.d.Repo.UserForOwner(ctx, o.ID, provider, n.Prop("hubspot_owner_id")); u != nil {
|
|
author = *u
|
|
}
|
|
body := htmlToText(n.Prop("hs_note_body"))
|
|
if body == "" {
|
|
continue
|
|
}
|
|
if _, _, err := s.d.Repo.MirrorNote(ctx, &repository.MirrorNote{
|
|
OrganizationID: o.ID, Provider: provider, ExternalID: n.ID, ContactID: contactID, UserID: author,
|
|
Content: body, CreatedAt: firstTime(parseHSTime(n.Prop("hs_timestamp")), parseHSTime(n.Prop("hs_createdate"))),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
s.notify(ctx, o.ID, contactID.String(), "contact", "deal", "task", "note")
|
|
return nil
|
|
}
|
|
|
|
// refreshObject pulls one record HubSpot told us changed.
|
|
func (s *Service) refreshObject(ctx context.Context, o *org, objectType, ext string, deleted bool) error {
|
|
switch objectType {
|
|
case "contact":
|
|
rec, err := s.d.Repo.GetContactRecordByExternal(ctx, o.ID, provider, ext)
|
|
if err != nil || rec == nil {
|
|
return err
|
|
}
|
|
return s.refreshContact(ctx, o, rec.ContactID, ext)
|
|
case "deal":
|
|
if deleted {
|
|
return s.d.Repo.DeleteMirrored(ctx, o.ID, provider, models.CRMObjectDeal, ext)
|
|
}
|
|
d, err := o.Client.GetObject(ctx, "deals", ext, dealProps, []string{"contacts"})
|
|
if err != nil {
|
|
if ae, ok := AsAPIError(err); ok && ae.NotFound() {
|
|
return s.d.Repo.DeleteMirrored(ctx, o.ID, provider, models.CRMObjectDeal, ext)
|
|
}
|
|
return err
|
|
}
|
|
if len(o.Config.DealPipelines) > 0 && !slices.Contains(o.Config.DealPipelines, d.Prop("pipeline")) {
|
|
return nil
|
|
}
|
|
local, err := s.localContacts(ctx, o, d.Associated("contacts"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var contactID *uuid.UUID
|
|
for _, id := range local {
|
|
contactID = &id
|
|
break
|
|
}
|
|
created, _, err := s.mirrorDeal(ctx, o, d, contactID)
|
|
if err == nil && created && contactID != nil && o.Config.ExitRules.DealCreated && recentlyCreated(d) {
|
|
s.holdContact(ctx, o, *contactID, "A deal was opened in HubSpot: "+d.Prop("dealname"))
|
|
}
|
|
s.notify(ctx, o.ID, contactIDString(contactID), "deal")
|
|
return err
|
|
case "task":
|
|
if deleted {
|
|
return s.d.Repo.DeleteMirrored(ctx, o.ID, provider, models.CRMObjectTask, ext)
|
|
}
|
|
t, err := o.Client.GetObject(ctx, "tasks", ext, taskProps, []string{"contacts", "deals"})
|
|
if err != nil {
|
|
if ae, ok := AsAPIError(err); ok && ae.NotFound() {
|
|
return s.d.Repo.DeleteMirrored(ctx, o.ID, provider, models.CRMObjectTask, ext)
|
|
}
|
|
return err
|
|
}
|
|
known, err := s.d.Repo.GetLinkByExternal(ctx, o.ID, provider, models.CRMObjectTask, ext)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mapped, err := s.d.Repo.UserForOwner(ctx, o.ID, provider, t.Prop("hubspot_owner_id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if known == nil && mapped == nil {
|
|
// Not a member's task and not one Warmbly shows: nothing to mirror.
|
|
return nil
|
|
}
|
|
local, err := s.localContacts(ctx, o, t.Associated("contacts"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var contactID, dealID *uuid.UUID
|
|
for _, id := range local {
|
|
contactID = &id
|
|
break
|
|
}
|
|
for _, ext := range t.Associated("deals") {
|
|
if l, _ := s.d.Repo.GetLinkByExternal(ctx, o.ID, provider, models.CRMObjectDeal, ext); l != nil {
|
|
id := l.LocalID
|
|
dealID = &id
|
|
break
|
|
}
|
|
}
|
|
fallback, err := s.d.Repo.FallbackActor(ctx, o.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.mirrorTask(ctx, o, t, contactID, dealID, fallback); err != nil {
|
|
return err
|
|
}
|
|
s.notify(ctx, o.ID, contactIDString(contactID), "task")
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown object type %q", objectType)
|
|
}
|
|
}
|