mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 16:02:45 +00:00
410 lines
12 KiB
Go
410 lines
12 KiB
Go
package pipedrive
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/crm"
|
|
"github.com/warmbly/warmbly/internal/app/crmmode"
|
|
"github.com/warmbly/warmbly/internal/app/integration"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
const provider = models.CRMProviderPipedrive
|
|
|
|
var (
|
|
_ crm.External = (*Service)(nil)
|
|
_ crmmode.Provider = (*Service)(nil)
|
|
)
|
|
|
|
// Tokens hands out a connection's current OAuth token (the integration service).
|
|
type Tokens interface {
|
|
AccessToken(ctx context.Context, orgID, connID uuid.UUID) (string, *models.IntegrationConnection, error)
|
|
GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error)
|
|
MarkConnectionHealth(ctx context.Context, connID uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string)
|
|
MergeConnectionDisplay(ctx context.Context, connID uuid.UUID, patch map[string]any) error
|
|
}
|
|
|
|
// Contacts reads Warmbly contacts.
|
|
type Contacts interface {
|
|
GetByIDsAndOrganization(ctx context.Context, organizationID uuid.UUID, ids []uuid.UUID) ([]models.Contact, *errx.Error)
|
|
GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error)
|
|
}
|
|
|
|
// LeadHolder parks a contact's campaigns (the CRM exit rules).
|
|
type LeadHolder interface {
|
|
HoldLeadEverywhere(ctx context.Context, contactID uuid.UUID, until *time.Time, reason, source string) ([]uuid.UUID, error)
|
|
}
|
|
|
|
// Suppressor adds an address to the workspace suppression list.
|
|
type Suppressor interface {
|
|
UpsertSuppressedRecipient(ctx context.Context, entry *models.SuppressedRecipient) error
|
|
}
|
|
|
|
// Importer starts a contact import draft from CSV.
|
|
type Importer interface {
|
|
Create(ctx context.Context, orgID, userID uuid.UUID, r io.Reader, filename string) (*models.ContactImport, *errx.Error)
|
|
}
|
|
|
|
// Realtime tells the dashboard mirrored data moved.
|
|
type Realtime interface {
|
|
PublishCRMSynced(ctx context.Context, orgID uuid.UUID, objects []string, contactID string)
|
|
}
|
|
|
|
// Deps are the Service's collaborators. Importer, Leads, Holds, Suppress and
|
|
// Realtime may be nil in processes that do not need them.
|
|
type Deps struct {
|
|
Repo repository.CRMProviderRepository
|
|
CRM repository.CRMRepository
|
|
Tokens Tokens
|
|
Contacts Contacts
|
|
Holds LeadHolder
|
|
Suppress Suppressor
|
|
Importer Importer
|
|
Leads Leads
|
|
Realtime Realtime
|
|
Cache *cache.Cache
|
|
// AppURL is the dashboard origin, for the "Open in Warmbly" field.
|
|
AppURL string
|
|
// PublicURL is the backend's public origin, where Pipedrive delivers webhooks.
|
|
PublicURL string
|
|
// ClientSecret derives each connection's webhook password
|
|
// (PIPEDRIVE_OAUTH_CLIENT_SECRET).
|
|
ClientSecret string
|
|
}
|
|
|
|
// Service is the Pipedrive CRM mode for every workspace on the instance.
|
|
type Service struct {
|
|
d Deps
|
|
|
|
mu sync.Mutex
|
|
settings map[uuid.UUID]cachedSettings
|
|
clients map[uuid.UUID]*orgClient
|
|
}
|
|
|
|
type orgClient struct {
|
|
*Client
|
|
connID uuid.UUID
|
|
base string
|
|
// probed is when a failed host discovery fell back to the shared host;
|
|
// zero once the company host is known.
|
|
probed time.Time
|
|
}
|
|
|
|
// discoverAgain spaces out host discovery after it failed.
|
|
const discoverAgain = 5 * time.Minute
|
|
|
|
type cachedSettings struct {
|
|
row *repository.CRMSettingsRow
|
|
at time.Time
|
|
}
|
|
|
|
const settingsTTL = 30 * time.Second
|
|
|
|
func New(d Deps) *Service {
|
|
return &Service{d: d, settings: map[uuid.UUID]cachedSettings{}, clients: map[uuid.UUID]*orgClient{}}
|
|
}
|
|
|
|
// Name is the CRM this service runs.
|
|
func (s *Service) Name() models.CRMProvider { return provider }
|
|
|
|
// Integration is the connection type Pipedrive mode runs on.
|
|
func (s *Service) Integration() models.IntegrationProvider { return models.IntegrationPipedrive }
|
|
|
|
// org is a workspace in Pipedrive mode, resolved for one operation.
|
|
type org struct {
|
|
ID uuid.UUID
|
|
ConnID uuid.UUID
|
|
Company string
|
|
Domain string
|
|
Config models.CRMProviderConfig
|
|
Client *Client
|
|
Settings *repository.CRMSettingsRow
|
|
}
|
|
|
|
func (s *Service) settingsRow(ctx context.Context, orgID uuid.UUID) (*repository.CRMSettingsRow, error) {
|
|
s.mu.Lock()
|
|
if c, ok := s.settings[orgID]; ok && time.Since(c.at) < settingsTTL {
|
|
s.mu.Unlock()
|
|
return c.row, nil
|
|
}
|
|
s.mu.Unlock()
|
|
row, err := s.d.Repo.GetSettings(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.settings[orgID] = cachedSettings{row: row, at: time.Now()}
|
|
s.mu.Unlock()
|
|
return row, nil
|
|
}
|
|
|
|
// Forget drops this process's cached view of a workspace's mode.
|
|
func (s *Service) Forget(orgID uuid.UUID) {
|
|
s.mu.Lock()
|
|
delete(s.settings, orgID)
|
|
delete(s.clients, orgID)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Active reports whether the workspace runs its CRM on Pipedrive.
|
|
func (s *Service) Active(ctx context.Context, orgID uuid.UUID) bool {
|
|
row, err := s.settingsRow(ctx, orgID)
|
|
return err == nil && row != nil && row.Provider == provider && row.ConnectionID != nil
|
|
}
|
|
|
|
var errNotConnected = errors.New("pipedrive is not connected")
|
|
|
|
// resolve returns the workspace's Pipedrive context, or nil when it is not in
|
|
// Pipedrive mode.
|
|
func (s *Service) resolve(ctx context.Context, orgID uuid.UUID) (*org, error) {
|
|
row, err := s.settingsRow(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row == nil || row.Provider != provider || row.ConnectionID == nil {
|
|
return nil, nil
|
|
}
|
|
conn, err := s.d.Tokens.GetConnection(ctx, orgID, *row.ConnectionID)
|
|
if err != nil || conn == nil {
|
|
return nil, errNotConnected
|
|
}
|
|
return s.orgFor(ctx, orgID, conn, row), nil
|
|
}
|
|
|
|
func (s *Service) orgFor(ctx context.Context, orgID uuid.UUID, conn *models.IntegrationConnection, row *repository.CRMSettingsRow) *org {
|
|
o := &org{
|
|
ID: orgID,
|
|
ConnID: conn.ID,
|
|
Company: displayString(conn.DisplayFields, "company_id"),
|
|
Domain: displayString(conn.DisplayFields, "company_domain"),
|
|
Settings: row,
|
|
}
|
|
if row != nil {
|
|
o.Config = row.Config
|
|
}
|
|
s.mu.Lock()
|
|
cached := s.clients[orgID]
|
|
s.mu.Unlock()
|
|
base, _ := integration.PipedriveAPIDomain(displayString(conn.DisplayFields, "api_domain"))
|
|
var probed time.Time
|
|
if base == "" && cached != nil && cached.connID == conn.ID &&
|
|
(cached.probed.IsZero() || time.Since(cached.probed) < discoverAgain) {
|
|
base, probed = cached.base, cached.probed
|
|
}
|
|
if base == "" {
|
|
var found bool
|
|
if base, found = s.discoverDomain(ctx, orgID, conn); !found {
|
|
probed = time.Now()
|
|
}
|
|
}
|
|
if o.Domain == "" {
|
|
o.Domain = domainOf(base)
|
|
}
|
|
if o.Company == "" {
|
|
o.Company = "conn-" + conn.ID.String()
|
|
}
|
|
s.mu.Lock()
|
|
cl := s.clients[orgID]
|
|
if cl == nil || cl.connID != conn.ID || cl.base != base || cl.probed != probed {
|
|
connID := conn.ID
|
|
cl = &orgClient{Client: NewClient(o.Company, base, func(ctx context.Context) (string, error) {
|
|
tok, _, err := s.d.Tokens.AccessToken(ctx, orgID, connID)
|
|
return tok, err
|
|
}, s.d.Cache), connID: connID, base: base, probed: probed}
|
|
s.clients[orgID] = cl
|
|
}
|
|
s.mu.Unlock()
|
|
o.Client = cl.Client
|
|
return o
|
|
}
|
|
|
|
// discoverDomain finds the company host of a connection made before Warmbly
|
|
// recorded it, and remembers it on the connection.
|
|
func (s *Service) discoverDomain(ctx context.Context, orgID uuid.UUID, conn *models.IntegrationConnection) (string, bool) {
|
|
const fallback = "https://api.pipedrive.com"
|
|
connID := conn.ID
|
|
probe := NewClient("conn-"+connID.String(), fallback, func(ctx context.Context) (string, error) {
|
|
tok, _, err := s.d.Tokens.AccessToken(ctx, orgID, connID)
|
|
return tok, err
|
|
}, s.d.Cache)
|
|
me, err := probe.Me(ctx)
|
|
if err != nil || me.CompanyDomain == "" {
|
|
return fallback, false
|
|
}
|
|
base, err := integration.PipedriveAPIDomain("https://" + strings.ToLower(me.CompanyDomain) + ".pipedrive.com")
|
|
if err != nil {
|
|
return fallback, false
|
|
}
|
|
_ = s.d.Tokens.MergeConnectionDisplay(ctx, connID, map[string]any{
|
|
"api_domain": base, "company_domain": strings.ToLower(me.CompanyDomain), "company_id": id(me.CompanyID),
|
|
})
|
|
return base, true
|
|
}
|
|
|
|
// domainOf is the company subdomain of an API host.
|
|
func domainOf(base string) string {
|
|
host := strings.TrimPrefix(base, "https://")
|
|
sub, _, _ := strings.Cut(host, ".")
|
|
if sub == "api" {
|
|
return ""
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// mustResolve is resolve for paths that only run in Pipedrive mode.
|
|
func (s *Service) mustResolve(ctx context.Context, orgID uuid.UUID) (*org, *errx.Error) {
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil {
|
|
return nil, s.userError(ctx, nil, err)
|
|
}
|
|
if o == nil {
|
|
return nil, errx.NewWithIdentifier(errx.Conflict, "crm_not_connected", "Connect Pipedrive and choose it as your CRM first.")
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// userError turns a provider failure into the answer a person can act on, and
|
|
// flags the connection when Pipedrive stopped trusting the token.
|
|
func (s *Service) userError(ctx context.Context, o *org, err error) *errx.Error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var xe *errx.Error
|
|
if errors.As(err, &xe) {
|
|
return xe
|
|
}
|
|
if errors.Is(err, errNotConnected) {
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_reauth_required", "Pipedrive is disconnected. Reconnect it in Integrations > Pipedrive.")
|
|
}
|
|
if ae, ok := AsAPIError(err); ok {
|
|
switch {
|
|
case ae.Status == 401:
|
|
if o != nil {
|
|
s.d.Tokens.MarkConnectionHealth(ctx, o.ConnID, models.IntegrationStatusReauthRequired, models.IntegrationHealthDown,
|
|
"Pipedrive refused the token: reconnect required")
|
|
}
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_reauth_required",
|
|
"Reconnect Pipedrive: Warmbly's access was revoked.")
|
|
case ae.Status == 403:
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_reauth_required",
|
|
"Pipedrive refused this for the connected user. Reconnect Pipedrive as a user who can do it, or ask a Pipedrive admin.")
|
|
case ae.Status == 402:
|
|
return errx.NewWithIdentifier(errx.Unprocessable, "crm_provider_rejected",
|
|
"Your Pipedrive plan does not include this feature.")
|
|
case ae.Retryable():
|
|
return errx.NewWithIdentifier(errx.ServiceUnavailable, "crm_unavailable", "Pipedrive did not answer. Try again in a moment.")
|
|
case ae.NotFound():
|
|
return errx.NewWithIdentifier(errx.NotFound, "crm_record_missing", "This record no longer exists in Pipedrive.")
|
|
default:
|
|
return errx.NewWithIdentifier(errx.Unprocessable, "crm_provider_rejected", "Pipedrive refused the change: "+ae.Message)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), "token refresh failed") || strings.Contains(err.Error(), "connection not found") {
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_reauth_required", "Reconnect Pipedrive: Warmbly's access was revoked.")
|
|
}
|
|
log.Warn().Err(err).Msg("pipedrive: call failed")
|
|
return errx.NewWithIdentifier(errx.ServiceUnavailable, "crm_unavailable", "Pipedrive did not answer. Try again in a moment.")
|
|
}
|
|
|
|
func (s *Service) notify(ctx context.Context, orgID uuid.UUID, contactID string, objects ...string) {
|
|
if s.d.Realtime != nil {
|
|
s.d.Realtime.PublishCRMSynced(ctx, orgID, objects, contactID)
|
|
}
|
|
}
|
|
|
|
func displayString(raw []byte, key string) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
m := map[string]any{}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return ""
|
|
}
|
|
switch v := m[key].(type) {
|
|
case string:
|
|
return v
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---------- links into Pipedrive ----------
|
|
|
|
func (o *org) web() string {
|
|
if o.Domain == "" {
|
|
return "https://app.pipedrive.com"
|
|
}
|
|
return "https://" + o.Domain + ".pipedrive.com"
|
|
}
|
|
|
|
func (o *org) personURL(ext string) string {
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
return o.web() + "/person/" + ext
|
|
}
|
|
|
|
func (o *org) dealURL(ext string) string {
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
return o.web() + "/deal/" + ext
|
|
}
|
|
|
|
func (o *org) orgURL(ext string) string {
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
return o.web() + "/organization/" + ext
|
|
}
|
|
|
|
func (o *org) pipelineURL(ext string) string {
|
|
if ext == "" {
|
|
return o.web() + "/pipeline"
|
|
}
|
|
return o.web() + "/pipeline/" + ext
|
|
}
|
|
|
|
func (o *org) activitiesURL() string { return o.web() + "/activities/list" }
|
|
|
|
// ---------- ids ----------
|
|
|
|
// extID parses a Pipedrive id stored as text; 0 when it is not one.
|
|
func extID(v string) int64 {
|
|
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func ptrID(p *int64) string {
|
|
if p == nil || *p == 0 {
|
|
return ""
|
|
}
|
|
return id(*p)
|
|
}
|
|
|
|
func firstNonEmpty(vs ...string) string {
|
|
for _, v := range vs {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|