Files
warmbly/internal/app/pipedrive/settings.go
T

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
}