package pipedrive
import (
"context"
"encoding/json"
"fmt"
"html"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// ---------- time ----------
// parseTime reads Pipedrive's timestamps: RFC 3339 (v2), "2006-01-02 15:04:05"
// in UTC (v1) and plain dates.
func parseTime(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, v); err == nil {
t = t.UTC()
return &t
}
}
return nil
}
func parsePtrTime(v *string) *time.Time {
if v == nil {
return nil
}
return parseTime(*v)
}
func pdDate(t time.Time) string { return t.UTC().Format("2006-01-02") }
func pdClock(t time.Time) string { return t.UTC().Format("15:04") }
func pdTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
// dueAt joins an activity's due date and UTC time.
func dueAt(date, clock string) *time.Time {
d := parseTime(date)
if d == nil {
return nil
}
if clock = strings.TrimSpace(clock); clock != "" {
for _, layout := range []string{"15:04:05", "15:04"} {
if c, err := time.Parse(layout, clock); err == nil {
t := time.Date(d.Year(), d.Month(), d.Day(), c.Hour(), c.Minute(), c.Second(), 0, time.UTC)
return &t
}
}
}
return d
}
// ---------- activity types ----------
// defaultActivityTypes are Pipedrive's built-in types, used until the
// company's own list is read.
var defaultActivityTypes = []ActivityType{
{Name: "Call", KeyString: "call", ActiveFlag: true},
{Name: "Meeting", KeyString: "meeting", ActiveFlag: true},
{Name: "Task", KeyString: "task", ActiveFlag: true},
{Name: "Deadline", KeyString: "deadline", ActiveFlag: true},
{Name: "Email", KeyString: "email", ActiveFlag: true},
{Name: "Lunch", KeyString: "lunch", ActiveFlag: true},
}
var activityColors = map[string]string{
"call": "#8b5cf6", "meeting": "#f59e0b", "task": "#64748b", "deadline": "#ef4444", "email": "#0ea5e9", "lunch": "#14b8a6",
}
// activityTypeNamespace keeps the synthetic task type ids stable across calls.
var activityTypeNamespace = uuid.MustParse("3b6f2a8e-1c4d-4f7a-9e25-7d0c5b1a9f42")
// activityTypes is the company's activity types, cached for an hour.
func (s *Service) activityTypes(ctx context.Context, o *org) []ActivityType {
key := "pipedrive:acttypes:" + o.Company
if s.d.Cache != nil {
if raw, err := s.d.Cache.Get(ctx, key).Bytes(); err == nil && len(raw) > 0 {
var out []ActivityType
if json.Unmarshal(raw, &out) == nil && len(out) > 0 {
return out
}
}
}
types, err := o.Client.ActivityTypes(ctx)
if err != nil || len(types) == 0 {
return defaultActivityTypes
}
active := types[:0]
for _, t := range types {
if t.ActiveFlag {
active = append(active, t)
}
}
sort.SliceStable(active, func(i, j int) bool { return active[i].OrderNr < active[j].OrderNr })
if s.d.Cache != nil {
_ = s.d.Cache.SetJSON(ctx, key, active, time.Hour)
}
return active
}
// TaskTypes is the company's activity types, in the shape the pickers read.
func (s *Service) TaskTypes(ctx context.Context, orgID uuid.UUID) []models.CRMTaskType {
types := defaultActivityTypes
if o, err := s.resolve(ctx, orgID); err == nil && o != nil {
types = s.activityTypes(ctx, o)
}
out := make([]models.CRMTaskType, 0, len(types))
for i, t := range types {
color := t.Color
if color == "" || !strings.HasPrefix(color, "#") {
color = activityColors[t.KeyString]
}
if color == "" {
color = "#64748b"
}
out = append(out, models.CRMTaskType{
ID: uuid.NewSHA1(activityTypeNamespace, []byte(t.KeyString)),
OrganizationID: orgID,
Name: t.Name,
Color: color,
Position: i,
})
}
return out
}
// activityKey picks the Pipedrive activity type for a Warmbly task type name.
func (s *Service) activityKey(ctx context.Context, o *org, name string) string {
n := strings.ToLower(strings.TrimSpace(name))
for _, t := range s.activityTypes(ctx, o) {
if strings.ToLower(t.Name) == n || t.KeyString == n {
return t.KeyString
}
}
switch {
case strings.Contains(n, "call") || strings.Contains(n, "phone"):
return "call"
case strings.Contains(n, "meet"):
return "meeting"
case n == "email" || n == "e-mail":
return "email"
case strings.Contains(n, "deadline"):
return "deadline"
default:
return "task"
}
}
// activityName is the Warmbly task type name for a Pipedrive activity type.
func (s *Service) activityName(ctx context.Context, o *org, key string) string {
for _, t := range s.activityTypes(ctx, o) {
if t.KeyString == key {
return t.Name
}
}
if key == "" {
return "Task"
}
return strings.ToUpper(key[:1]) + key[1:]
}
// ---------- the Warmbly person fields ----------
// The Warmbly fields on Pipedrive persons: what people filter on, build
// reports and automations from. Matched by name, created when missing.
const (
fieldStatus = "Warmbly status"
fieldLastCampaign = "Warmbly last campaign"
fieldLastContacted = "Warmbly last contacted"
fieldLastReplied = "Warmbly last replied"
fieldReplyIntent = "Warmbly reply intent"
fieldLastOpened = "Warmbly last opened"
fieldLastClicked = "Warmbly last clicked"
fieldLink = "Open in Warmbly"
)
// Values of the Warmbly status field.
const (
statusContacted = "Contacted"
statusReplied = "Replied"
statusInterested = "Interested"
statusNotInterest = "Not interested"
statusMeeting = "Meeting booked"
statusBounced = "Bounced"
statusUnsubscribed = "Unsubscribed"
)
// statusKey is where the last written status is remembered on the record.
const statusKey = "warmbly_status"
type fieldDef struct {
Name string
Type string
Options []string
}
var warmblyFields = []fieldDef{
{Name: fieldStatus, Type: "enum", Options: []string{statusContacted, statusReplied, statusInterested, statusNotInterest,
statusMeeting, statusBounced, statusUnsubscribed}},
{Name: fieldLastCampaign, Type: "varchar"},
{Name: fieldLastContacted, Type: "date"},
{Name: fieldLastReplied, Type: "date"},
{Name: fieldReplyIntent, Type: "varchar"},
{Name: fieldLastOpened, Type: "date"},
{Name: fieldLastClicked, Type: "date"},
{Name: fieldLink, Type: "varchar"},
}
// warmblyFieldSet maps a Warmbly field name to its Pipedrive field code, and
// the status options to their ids.
type warmblyFieldSet struct {
Codes map[string]string `json:"codes"`
Options map[string]int64 `json:"options"`
}
// ---------- text ----------
var (
reBreak = regexp.MustCompile(`(?i)
|