Files
warmbly/internal/app/hubspot/mapping.go
T

337 lines
10 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hubspot
import (
"context"
"html"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// The "Warmbly" property group on contacts: what HubSpot users filter lists,
// build reports and trigger workflows on.
const (
propGroup = "warmbly"
propStatus = "warmbly_status"
propLastCampaign = "warmbly_last_campaign"
propLastContacted = "warmbly_last_contacted_at"
propLastReplied = "warmbly_last_replied_at"
propReplyIntent = "warmbly_reply_intent"
propLastOpened = "warmbly_last_opened_at"
propLastClicked = "warmbly_last_clicked_at"
propUnsubscribed = "warmbly_unsubscribed"
propLink = "warmbly_link"
)
// Values of warmbly_status.
const (
statusContacted = "contacted"
statusReplied = "replied"
statusInterested = "interested"
statusNotInterest = "not_interested"
statusBounced = "bounced"
statusUnsubscribed = "unsubscribed"
statusMeeting = "meeting_booked"
)
var warmblyProperties = []PropertyDef{
{Name: propStatus, Label: "Warmbly status", Type: "enumeration", FieldType: "select", GroupName: propGroup,
Description: "Where this contact stands in Warmbly outreach.",
Options: []PropertyOption{
{Label: "Contacted", Value: statusContacted, DisplayOrder: 0},
{Label: "Replied", Value: statusReplied, DisplayOrder: 1},
{Label: "Interested", Value: statusInterested, DisplayOrder: 2},
{Label: "Not interested", Value: statusNotInterest, DisplayOrder: 3},
{Label: "Meeting booked", Value: statusMeeting, DisplayOrder: 4},
{Label: "Bounced", Value: statusBounced, DisplayOrder: 5},
{Label: "Unsubscribed", Value: statusUnsubscribed, DisplayOrder: 6},
}},
{Name: propLastCampaign, Label: "Warmbly last campaign", Type: "string", FieldType: "text", GroupName: propGroup},
{Name: propLastContacted, Label: "Warmbly last contacted", Type: "datetime", FieldType: "date", GroupName: propGroup},
{Name: propLastReplied, Label: "Warmbly last replied", Type: "datetime", FieldType: "date", GroupName: propGroup},
{Name: propReplyIntent, Label: "Warmbly reply intent", Type: "string", FieldType: "text", GroupName: propGroup,
Description: "How Warmbly classified the contact's latest reply."},
{Name: propLastOpened, Label: "Warmbly last opened", Type: "datetime", FieldType: "date", GroupName: propGroup},
{Name: propLastClicked, Label: "Warmbly last clicked", Type: "datetime", FieldType: "date", GroupName: propGroup},
{Name: propUnsubscribed, Label: "Warmbly unsubscribed", Type: "bool", FieldType: "booleancheckbox", GroupName: propGroup,
Options: []PropertyOption{{Label: "Yes", Value: "true", DisplayOrder: 0}, {Label: "No", Value: "false", DisplayOrder: 1}}},
{Name: propLink, Label: "Open in Warmbly", Type: "string", FieldType: "text", GroupName: propGroup},
}
// ensureProperties creates the Warmbly property group in the portal. Only a
// success is remembered, so a failed attempt is retried by the next caller.
func (s *Service) ensureProperties(ctx context.Context, o *org) error {
key := "hubspot:propsready:" + o.Portal
if s.d.Cache != nil {
if ok, _ := s.d.Cache.Exists(ctx, key).Result(); ok > 0 {
return nil
}
}
if err := o.Client.EnsureContactProperties(ctx, propGroup, "Warmbly", warmblyProperties); err != nil {
return err
}
if s.d.Cache != nil {
s.d.Cache.Set(ctx, key, 1, 6*time.Hour)
}
return nil
}
// propertiesReady makes sure the properties a job writes exist first.
func (s *Service) propertiesReady(ctx context.Context, o *org) error {
if !o.Config.WriteProperties {
return nil
}
return s.ensureProperties(ctx, o)
}
func hsTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
// parseHSTime reads a datetime property (ISO 8601 or epoch milliseconds).
func parseHSTime(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
t := time.UnixMilli(ms).UTC()
return &t
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, v); err == nil {
t = t.UTC()
return &t
}
}
return nil
}
// ---------- tasks ----------
type taskTypeOption struct{ value, label string }
// taskTypeOptions are HubSpot's task types as Warmbly shows them.
var taskTypeOptions = []taskTypeOption{
{"TODO", "To-do"}, {"CALL", "Call"}, {"EMAIL", "Email"}, {"LINKED_IN", "LinkedIn"},
}
var taskTypeColors = map[string]string{"TODO": "#64748b", "CALL": "#8b5cf6", "EMAIL": "#0ea5e9", "LINKED_IN": "#0a66c2"}
// taskTypeNamespace keeps the synthetic task type ids stable across calls.
var taskTypeNamespace = uuid.MustParse("6f1d7b2e-6d0c-4c1e-9a52-6d1f0c1b8a01")
// TaskTypes is the fixed HubSpot task type set, in the shape the pickers read.
func (s *Service) TaskTypes(ctx context.Context, orgID uuid.UUID) []models.CRMTaskType {
out := make([]models.CRMTaskType, 0, len(taskTypeOptions))
for i, t := range taskTypeOptions {
out = append(out, models.CRMTaskType{
ID: uuid.NewSHA1(taskTypeNamespace, []byte(t.value)),
OrganizationID: orgID,
Name: t.label,
Color: taskTypeColors[t.value],
Position: i,
})
}
return out
}
func hsTaskType(name string) string {
n := strings.ToLower(strings.TrimSpace(name))
switch {
case n == "call" || strings.Contains(n, "phone"):
return "CALL"
case n == "email" || n == "e-mail":
return "EMAIL"
case strings.Contains(n, "linkedin"):
return "LINKED_IN"
default:
return "TODO"
}
}
func localTaskType(v string) string {
switch strings.ToUpper(v) {
case "CALL":
return "Call"
case "EMAIL":
return "Email"
case "LINKED_IN", "LINKED_IN_CONNECT", "LINKED_IN_MESSAGE":
return "LinkedIn"
default:
return "To-do"
}
}
func hsTaskStatus(s models.CRMTaskStatus) string {
switch s {
case models.CRMTaskStatusCompleted:
return "COMPLETED"
case models.CRMTaskStatusInProgress:
return "IN_PROGRESS"
case models.CRMTaskStatusCancelled:
return "DEFERRED"
default:
return "NOT_STARTED"
}
}
func localTaskStatus(v string) models.CRMTaskStatus {
switch strings.ToUpper(v) {
case "COMPLETED":
return models.CRMTaskStatusCompleted
case "IN_PROGRESS", "WAITING":
return models.CRMTaskStatusInProgress
case "DEFERRED":
return models.CRMTaskStatusCancelled
default:
return models.CRMTaskStatusPending
}
}
func hsTaskPriority(p models.CRMTaskPriority) string {
switch p {
case models.CRMTaskPriorityLow:
return "LOW"
case models.CRMTaskPriorityHigh, models.CRMTaskPriorityUrgent:
return "HIGH"
default:
return "MEDIUM"
}
}
func localTaskPriority(v string) models.CRMTaskPriority {
switch strings.ToUpper(v) {
case "HIGH":
return models.CRMTaskPriorityHigh
case "LOW", "NONE":
return models.CRMTaskPriorityLow
default:
return models.CRMTaskPriorityMedium
}
}
var taskProps = []string{"hs_task_subject", "hs_task_body", "hs_timestamp", "hs_task_status", "hs_task_priority",
"hs_task_type", "hubspot_owner_id", "hs_task_completion_date", "hs_createdate", "hs_lastmodifieddate"}
// ---------- deals ----------
var dealProps = []string{"dealname", "amount", "dealstage", "pipeline", "closedate", "hubspot_owner_id",
"deal_currency_code", "createdate", "hs_lastmodifieddate", "closed_lost_reason", "hs_is_closed_won", "hs_is_closed"}
// stageMeta is what a mirrored stage remembers of HubSpot's metadata.
type stageMeta struct {
Closed bool
Won bool
Probability *float64
}
func stageMetaFrom(st Stage) stageMeta {
m := stageMeta{Closed: strings.EqualFold(st.Metadata["isClosed"], "true")}
if p, err := strconv.ParseFloat(st.Metadata["probability"], 64); err == nil {
m.Probability = &p
m.Won = m.Closed && p >= 1
}
return m
}
func (m stageMeta) toMap() map[string]any {
out := map[string]any{"closed": m.Closed, "won": m.Won}
if m.Probability != nil {
out["probability"] = *m.Probability
}
return out
}
func stageMetaFromMap(meta map[string]any) stageMeta {
m := stageMeta{}
m.Closed, _ = meta["closed"].(bool)
m.Won, _ = meta["won"].(bool)
if p, ok := meta["probability"].(float64); ok {
m.Probability = &p
}
return m
}
func (m stageMeta) status() models.DealStatus {
switch {
case m.Closed && m.Won:
return models.DealStatusWon
case m.Closed:
return models.DealStatusLost
default:
return models.DealStatusOpen
}
}
// stagePalette colours mirrored stages by position; HubSpot stages carry none.
var stagePalette = []string{"#0ea5e9", "#6366f1", "#8b5cf6", "#f59e0b", "#14b8a6", "#ec4899", "#64748b", "#84cc16"}
func stageColor(i int, m stageMeta) string {
switch {
case m.Closed && m.Won:
return "#10b981"
case m.Closed:
return "#ef4444"
default:
return stagePalette[i%len(stagePalette)]
}
}
// ---------- text ----------
var (
reBreak = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>`)
reTag = regexp.MustCompile(`<[^>]*>`)
reBlank = regexp.MustCompile(`\n{3,}`)
)
// htmlToText flattens HubSpot rich text (notes, task bodies) for Warmbly.
func htmlToText(s string) string {
s = reBreak.ReplaceAllString(s, "\n")
s = reTag.ReplaceAllString(s, "")
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, " ", " ")
return strings.TrimSpace(reBlank.ReplaceAllString(s, "\n\n"))
}
// textToHTML renders Warmbly plain text for HubSpot's rich text fields.
func textToHTML(s string) string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
for i, l := range lines {
lines[i] = html.EscapeString(l)
}
return strings.Join(lines, "<br>")
}
func firstNonEmpty(vs ...string) string {
for _, v := range vs {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func emailDomain(email string) string {
at := strings.LastIndex(email, "@")
if at < 0 {
return ""
}
d := strings.ToLower(strings.TrimSpace(email[at+1:]))
if freeMailDomains[d] {
return ""
}
return d
}
// freeMailDomains never become companies.
var freeMailDomains = map[string]bool{
"gmail.com": true, "googlemail.com": true, "yahoo.com": true, "outlook.com": true, "hotmail.com": true,
"live.com": true, "icloud.com": true, "me.com": true, "aol.com": true, "proton.me": true, "protonmail.com": true,
"gmx.com": true, "gmx.de": true, "mail.com": true, "yandex.com": true, "zoho.com": true, "msn.com": true,
}