mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 08:02:42 +00:00
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package pipedrive
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/integration"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Settings returns the workspace's CRM mode with the connected account.
|
|
func (s *Service) Settings(ctx context.Context, orgID uuid.UUID) (*models.CRMSettings, *errx.Error) {
|
|
row, err := s.d.Repo.GetSettings(ctx, orgID)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
out := &models.CRMSettings{OrganizationID: orgID, Provider: models.CRMProviderNative, Config: models.DefaultCRMProviderConfigFor(provider)}
|
|
if row != nil {
|
|
out.Provider = row.Provider
|
|
out.ConnectionID = row.ConnectionID
|
|
out.Config = row.Config
|
|
out.SetupCompletedAt = row.SetupCompletedAt
|
|
out.UpdatedAt = row.UpdatedAt
|
|
if !row.Config.BelongsTo(provider) && row.Provider != models.CRMProviderHubSpot {
|
|
out.Config = models.DefaultCRMProviderConfigFor(provider)
|
|
}
|
|
}
|
|
if out.ConnectionID != nil {
|
|
if conn, cerr := s.d.Tokens.GetConnection(ctx, orgID, *out.ConnectionID); cerr == nil && conn != nil {
|
|
out.Account = accountView(conn)
|
|
}
|
|
}
|
|
if out.Provider == provider && out.Account == nil {
|
|
// The Pipedrive connection was removed: the CRM is Warmbly's again.
|
|
out.Provider = models.CRMProviderNative
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func accountView(conn *models.IntegrationConnection) *models.CRMAccount {
|
|
acct := &models.CRMAccount{
|
|
ExternalID: firstNonEmpty(displayString(conn.DisplayFields, "company_id"), conn.ExternalAccountID),
|
|
Name: conn.ExternalAccountName,
|
|
Status: string(conn.Status),
|
|
Health: conn.Health,
|
|
}
|
|
domain := displayString(conn.DisplayFields, "company_domain")
|
|
if domain == "" {
|
|
if base, err := integration.PipedriveAPIDomain(displayString(conn.DisplayFields, "api_domain")); err == nil {
|
|
domain = domainOf(base)
|
|
}
|
|
}
|
|
if domain != "" {
|
|
acct.AppURL = "https://" + domain + ".pipedrive.com"
|
|
}
|
|
if len(conn.GrantedScopes) > 0 && !slices.Contains(conn.GrantedScopes, "admin") {
|
|
for _, sc := range integration.PipedriveRequiredScopes {
|
|
if !slices.Contains(conn.GrantedScopes, sc) {
|
|
acct.MissingScopes = append(acct.MissingScopes, sc)
|
|
}
|
|
}
|
|
}
|
|
return acct
|
|
}
|
|
|
|
// UpdateSettings switches the workspace's CRM and stores its choices. Choosing
|
|
// Pipedrive seeds the rules from the company's own labels, registers the
|
|
// webhooks and starts the first pull, so the dashboard has data by the time
|
|
// the wizard closes.
|
|
func (s *Service) UpdateSettings(ctx context.Context, orgID uuid.UUID, upd *models.UpdateCRMSettings) (*models.CRMSettings, *errx.Error) {
|
|
row, err := s.d.Repo.GetSettings(ctx, orgID)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
if row == nil {
|
|
row = &repository.CRMSettingsRow{OrganizationID: orgID, Provider: models.CRMProviderNative, Config: models.DefaultCRMProviderConfigFor(provider)}
|
|
}
|
|
wasPipedrive := row.Provider == provider
|
|
if upd.Provider != nil {
|
|
switch *upd.Provider {
|
|
case models.CRMProviderNative, models.CRMProviderPipedrive:
|
|
row.Provider = *upd.Provider
|
|
default:
|
|
return nil, errx.New(errx.BadRequest, "provider must be native or pipedrive")
|
|
}
|
|
if !wasPipedrive && row.Provider == provider && upd.ConnectionID == nil {
|
|
row.ConnectionID = nil
|
|
}
|
|
}
|
|
if upd.ConnectionID != nil {
|
|
row.ConnectionID = upd.ConnectionID
|
|
}
|
|
seed := false
|
|
if upd.Config != nil {
|
|
if verr := upd.Config.Validate(); verr != nil {
|
|
return nil, errx.New(errx.BadRequest, verr.Error())
|
|
}
|
|
row.Config = *upd.Config
|
|
row.Config.For = provider
|
|
} else if row.Provider == provider && !row.Config.BelongsTo(provider) {
|
|
row.Config = models.DefaultCRMProviderConfigFor(provider)
|
|
row.Config.For = provider
|
|
seed = true
|
|
}
|
|
var conn *models.IntegrationConnection
|
|
if row.Provider == provider {
|
|
if row.ConnectionID == nil {
|
|
return nil, errx.New(errx.BadRequest, "connection_id is required to use Pipedrive as the CRM")
|
|
}
|
|
c, cerr := s.d.Tokens.GetConnection(ctx, orgID, *row.ConnectionID)
|
|
if cerr != nil || c == nil || c.Provider != models.IntegrationPipedrive {
|
|
return nil, errx.New(errx.BadRequest, "connection_id must name a connected Pipedrive account")
|
|
}
|
|
if acct := accountView(c); len(acct.MissingScopes) > 0 {
|
|
return nil, errx.NewWithIdentifier(errx.Conflict, "crm_reauth_required",
|
|
"Reconnect Pipedrive to grant the permissions CRM mode needs (deals, people, activities and users).")
|
|
}
|
|
conn = c
|
|
}
|
|
if upd.CompleteSetup {
|
|
now := time.Now().UTC()
|
|
row.SetupCompletedAt = &now
|
|
}
|
|
if conn != nil && !wasPipedrive {
|
|
// A token Pipedrive refuses is found before the switch, not after it.
|
|
o := s.orgFor(ctx, orgID, conn, row)
|
|
if _, err := o.Client.Me(ctx); err != nil {
|
|
if xe := s.userError(ctx, o, err); xe.Identifier == "crm_reauth_required" {
|
|
s.Forget(orgID)
|
|
return nil, xe
|
|
}
|
|
}
|
|
if seed {
|
|
s.seedRules(ctx, o, &row.Config)
|
|
}
|
|
}
|
|
if err := s.d.Repo.UpsertSettings(ctx, row); err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
s.Forget(orgID)
|
|
|
|
if row.Provider == provider && !wasPipedrive {
|
|
go s.initialPull(orgID)
|
|
}
|
|
return s.Settings(ctx, orgID)
|
|
}
|
|
|
|
// seedRules fills the label rules from the company's own labels: an
|
|
// interested reply labels the person a hot lead, and a customer is never
|
|
// emailed by a campaign.
|
|
func (s *Service) seedRules(ctx context.Context, o *org, cfg *models.CRMProviderConfig) {
|
|
labels := s.labels(ctx, o)
|
|
if hot := labelByName(labels, "Hot lead", "Hot", "Warm lead"); hot != "" {
|
|
cfg.PositiveReply.LifecycleStage = hot
|
|
}
|
|
if customer := labelByName(labels, "Customer", "Client"); customer != "" {
|
|
cfg.ExitRules.LifecycleStages = []string{customer}
|
|
cfg.Guards.SkipLifecycleStages = []string{customer}
|
|
}
|
|
}
|
|
|
|
// initialPull fills the mirror right after a switch: users, pipelines,
|
|
// webhooks, then recent deals and activities.
|
|
func (s *Service) initialPull(orgID uuid.UUID) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Msg("pipedrive: initial pull panicked")
|
|
}
|
|
}()
|
|
key := "pull:" + orgID.String()
|
|
if !s.claim(ctx, key, 10*time.Minute) {
|
|
return
|
|
}
|
|
defer s.release(ctx, key)
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil || o == nil {
|
|
return
|
|
}
|
|
s.pullOrg(ctx, o, true)
|
|
}
|
|
|
|
// Metadata is the Pipedrive vocabulary the pickers render. Person labels take
|
|
// the place of lifecycle stages; Pipedrive has no lead status.
|
|
func (s *Service) Metadata(ctx context.Context, orgID uuid.UUID) (*models.CRMMetadata, *errx.Error) {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
fields, err := s.personFields(ctx, o)
|
|
if err != nil {
|
|
return nil, s.userError(ctx, o, err)
|
|
}
|
|
out := &models.CRMMetadata{
|
|
LifecycleStages: s.labels(ctx, o),
|
|
LeadStatuses: []models.CRMOption{},
|
|
TaskTypes: []models.CRMOption{},
|
|
Properties: append([]models.CRMProperty{}, standardPersonKeys...),
|
|
Pipelines: []models.CRMOption{},
|
|
}
|
|
if out.LifecycleStages == nil {
|
|
out.LifecycleStages = []models.CRMOption{}
|
|
}
|
|
var custom []models.CRMProperty
|
|
for _, f := range fields {
|
|
if !f.IsCustomField || f.FieldCode == "" {
|
|
continue
|
|
}
|
|
custom = append(custom, models.CRMProperty{
|
|
Name: f.FieldCode, Label: f.FieldName, Type: f.FieldType, GroupName: "Custom fields",
|
|
ReadOnly: !mappableTypes[f.FieldType] || strings.HasPrefix(strings.ToLower(f.FieldName), "warmbly "),
|
|
})
|
|
}
|
|
sort.Slice(custom, func(i, j int) bool { return strings.ToLower(custom[i].Label) < strings.ToLower(custom[j].Label) })
|
|
out.Properties = append(out.Properties, custom...)
|
|
for _, t := range s.activityTypes(ctx, o) {
|
|
out.TaskTypes = append(out.TaskTypes, models.CRMOption{Value: t.KeyString, Label: t.Name})
|
|
}
|
|
pipes, err := o.Client.Pipelines(ctx)
|
|
if err != nil {
|
|
return nil, s.userError(ctx, o, err)
|
|
}
|
|
slices.SortStableFunc(pipes, func(a, b Pipeline) int { return a.OrderNr - b.OrderNr })
|
|
for _, p := range pipes {
|
|
if !p.IsDeleted {
|
|
out.Pipelines = append(out.Pipelines, models.CRMOption{Value: id(p.ID), Label: p.Name})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Owners lists Pipedrive users with their member match.
|
|
func (s *Service) Owners(ctx context.Context, orgID uuid.UUID) ([]models.CRMOwner, *errx.Error) {
|
|
owners, err := s.d.Repo.ListOwners(ctx, orgID, provider)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
return owners, nil
|
|
}
|
|
|
|
// MapOwner pins (or clears) the member a Pipedrive user is.
|
|
func (s *Service) MapOwner(ctx context.Context, orgID uuid.UUID, externalID string, userID *uuid.UUID) *errx.Error {
|
|
if err := s.d.Repo.SetOwnerUser(ctx, orgID, provider, externalID, userID); err != nil {
|
|
if err == repository.ErrCRMRecordNotFound {
|
|
return errx.New(errx.NotFound, "owner or member not found")
|
|
}
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SyncHealth reports the outbox and the pulls.
|
|
func (s *Service) SyncHealth(ctx context.Context, orgID uuid.UUID) (*models.CRMSyncHealth, *errx.Error) {
|
|
h, err := s.d.Repo.SyncHealth(ctx, orgID, provider)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
// RetryFailed requeues failed jobs (all when ids is empty).
|
|
func (s *Service) RetryFailed(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int64, *errx.Error) {
|
|
n, err := s.d.Repo.RetryFailedJobs(ctx, orgID, provider, ids)
|
|
if err != nil {
|
|
return 0, errx.InternalError()
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// DiscardFailed drops failed jobs (all when ids is empty).
|
|
func (s *Service) DiscardFailed(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int64, *errx.Error) {
|
|
n, err := s.d.Repo.DiscardFailedJobs(ctx, orgID, provider, ids)
|
|
if err != nil {
|
|
return 0, errx.InternalError()
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// SyncNow runs a full pull for the workspace in the background.
|
|
func (s *Service) SyncNow(ctx context.Context, orgID uuid.UUID) *errx.Error {
|
|
if _, xerr := s.mustResolve(ctx, orgID); xerr != nil {
|
|
return xerr
|
|
}
|
|
if s.d.Cache != nil {
|
|
ok, err := s.d.Cache.SetNX(ctx, "pipedrive:syncnow:"+orgID.String(), 1, time.Minute).Result()
|
|
if err == nil && !ok {
|
|
return errx.NewWithIdentifier(errx.TooManyRequests, "crm_sync_running", "A sync just started. It finishes in a minute or two.")
|
|
}
|
|
}
|
|
go s.initialPull(orgID)
|
|
return nil
|
|
}
|