mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 08:02:06 +00:00
694 lines
21 KiB
Go
694 lines
21 KiB
Go
package pipedrive
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// Write-through: a dashboard change goes to Pipedrive first, so a refusal (a
|
|
// missing permission, a deleted record) reaches the person who made it.
|
|
// Records written by automations go through the outbox instead (EnqueuePush).
|
|
|
|
// syncBulkLimit is how many records a bulk change writes while the request
|
|
// waits; Pipedrive has no batch endpoint, so larger ones go through the outbox.
|
|
const syncBulkLimit = 25
|
|
|
|
// PipelinesManaged refuses pipeline edits in Pipedrive mode.
|
|
func (s *Service) PipelinesManaged() *errx.Error {
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_managed_externally",
|
|
"Pipelines are managed in Pipedrive. Edit them there and they update here within minutes.")
|
|
}
|
|
|
|
// TaskTypesManaged refuses task type edits in Pipedrive mode.
|
|
func (s *Service) TaskTypesManaged() *errx.Error {
|
|
return errx.NewWithIdentifier(errx.Conflict, "crm_managed_externally",
|
|
"Task types are Pipedrive's activity types while Pipedrive is your CRM. Edit them in Pipedrive's company settings.")
|
|
}
|
|
|
|
// stageInfo resolves a local stage to its Pipedrive stage and pipeline ids.
|
|
func (s *Service) stageInfo(ctx context.Context, o *org, stageID uuid.UUID) (int64, int64, *errx.Error) {
|
|
l, err := s.d.Repo.GetLinkByLocal(ctx, o.ID, provider, models.CRMObjectStage, stageID)
|
|
if err != nil {
|
|
return 0, 0, errx.InternalError()
|
|
}
|
|
if l == nil {
|
|
return 0, 0, errx.NewWithIdentifier(errx.BadRequest, "crm_stage_unknown",
|
|
"That stage is not one of your Pipedrive stages. Refresh the page and pick a Pipedrive stage.")
|
|
}
|
|
pipe, _ := l.Meta["pipeline"].(string)
|
|
return extID(l.ExternalID), extID(pipe), nil
|
|
}
|
|
|
|
func (s *Service) ownerFor(ctx context.Context, o *org, userID *uuid.UUID) string {
|
|
if userID == nil {
|
|
return ""
|
|
}
|
|
ext, _ := s.d.Repo.OwnerForUser(ctx, o.ID, provider, *userID)
|
|
return ext
|
|
}
|
|
|
|
func dealBody(name string, value *float64, currency string, closeDate *time.Time) map[string]any {
|
|
body := map[string]any{"title": name}
|
|
if value != nil {
|
|
body["value"] = *value
|
|
}
|
|
if c := strings.ToUpper(strings.TrimSpace(currency)); len(c) == 3 {
|
|
body["currency"] = c
|
|
}
|
|
if closeDate != nil {
|
|
body["expected_close_date"] = pdDate(*closeDate)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// PushDealCreate creates a deal that was just written locally and links it.
|
|
func (s *Service) PushDealCreate(ctx context.Context, orgID uuid.UUID, deal *models.Deal) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
stageExt, pipeExt, xerr := s.stageInfo(ctx, o, deal.StageID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
body := dealBody(deal.Name, deal.Value, deal.Currency, deal.ExpectedCloseDate)
|
|
body["stage_id"] = stageExt
|
|
if pipeExt != 0 {
|
|
body["pipeline_id"] = pipeExt
|
|
}
|
|
owner := s.ownerFor(ctx, o, deal.AssignedTo)
|
|
if deal.ContactID != nil {
|
|
ext, rec, err := s.ensureContact(ctx, o, *deal.ContactID)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if ext != "" {
|
|
body["person_id"] = extID(ext)
|
|
}
|
|
if rec != nil {
|
|
if rec.CompanyExternalID != "" {
|
|
body["org_id"] = extID(rec.CompanyExternalID)
|
|
}
|
|
if owner == "" {
|
|
owner = rec.OwnerExternalID
|
|
}
|
|
}
|
|
}
|
|
if owner != "" {
|
|
body["owner_id"] = extID(owner)
|
|
}
|
|
switch deal.Status {
|
|
case models.DealStatusWon:
|
|
body["status"] = "won"
|
|
case models.DealStatusLost:
|
|
body["status"] = "lost"
|
|
if deal.LostReason != nil && *deal.LostReason != "" {
|
|
body["lost_reason"] = *deal.LostReason
|
|
}
|
|
}
|
|
created, err := o.Client.CreateDeal(ctx, body)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if err := s.d.Repo.ClaimLink(ctx, &models.CRMExternalLink{OrganizationID: orgID, Provider: provider,
|
|
ObjectType: models.CRMObjectDeal, LocalID: deal.ID, ExternalID: id(created.ID), Meta: ownerMeta(owner)}); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
s.notify(ctx, orgID, contactIDString(deal.ContactID), "deal")
|
|
return nil
|
|
}
|
|
|
|
// PushDealUpdate writes a deal edit to Pipedrive before it lands locally. Won
|
|
// and lost are a deal's status in Pipedrive, as in Warmbly, so the edit
|
|
// passes through unchanged.
|
|
func (s *Service) PushDealUpdate(ctx context.Context, orgID uuid.UUID, before *models.Deal, data *models.UpdateDeal) (*models.UpdateDeal, *errx.Error) {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
link, err := s.d.Repo.GetLinkByLocal(ctx, orgID, provider, models.CRMObjectDeal, before.ID)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
if link == nil {
|
|
merged := *before
|
|
applyDealUpdate(&merged, data)
|
|
if xerr := s.PushDealCreate(ctx, orgID, &merged); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
return data, nil
|
|
}
|
|
body := map[string]any{}
|
|
if data.Name != nil {
|
|
body["title"] = *data.Name
|
|
}
|
|
if data.Value != nil {
|
|
body["value"] = *data.Value
|
|
}
|
|
if data.Currency != nil && len(strings.TrimSpace(*data.Currency)) == 3 {
|
|
body["currency"] = strings.ToUpper(strings.TrimSpace(*data.Currency))
|
|
}
|
|
if data.ExpectedCloseDate != nil {
|
|
body["expected_close_date"] = pdDate(*data.ExpectedCloseDate)
|
|
}
|
|
if data.AssignedTo != nil {
|
|
owner := s.ownerFor(ctx, o, data.AssignedTo)
|
|
if owner == "" {
|
|
return nil, errx.NewWithIdentifier(errx.BadRequest, "crm_owner_unmapped",
|
|
"That member is not a Pipedrive user yet. Match them to a Pipedrive user in Integrations > Pipedrive.")
|
|
}
|
|
body["owner_id"] = extID(owner)
|
|
}
|
|
if data.StageID != nil {
|
|
stageExt, pipeExt, xerr := s.stageInfo(ctx, o, *data.StageID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
body["stage_id"] = stageExt
|
|
if pipeExt != 0 {
|
|
body["pipeline_id"] = pipeExt
|
|
}
|
|
}
|
|
if data.Status != nil && *data.Status != string(before.Status) {
|
|
body["status"] = *data.Status
|
|
if *data.Status == string(models.DealStatusLost) && data.LostReason != nil && *data.LostReason != "" {
|
|
body["lost_reason"] = *data.LostReason
|
|
}
|
|
} else if data.LostReason != nil && before.Status == models.DealStatusLost {
|
|
body["lost_reason"] = *data.LostReason
|
|
}
|
|
if data.ContactID != nil && (before.ContactID == nil || *before.ContactID != *data.ContactID) {
|
|
if ext, _, err := s.ensureContact(ctx, o, *data.ContactID); err == nil && ext != "" {
|
|
body["person_id"] = extID(ext)
|
|
}
|
|
}
|
|
if len(body) > 0 {
|
|
if _, err := o.Client.UpdateDeal(ctx, extID(link.ExternalID), body); err != nil {
|
|
return nil, s.userError(ctx, o, err)
|
|
}
|
|
}
|
|
s.notify(ctx, orgID, contactIDString(before.ContactID), "deal")
|
|
return data, nil
|
|
}
|
|
|
|
func applyDealUpdate(d *models.Deal, u *models.UpdateDeal) {
|
|
if u.StageID != nil {
|
|
d.StageID = *u.StageID
|
|
}
|
|
if u.ContactID != nil {
|
|
d.ContactID = u.ContactID
|
|
}
|
|
if u.Name != nil {
|
|
d.Name = *u.Name
|
|
}
|
|
if u.Value != nil {
|
|
d.Value = u.Value
|
|
}
|
|
if u.Currency != nil {
|
|
d.Currency = *u.Currency
|
|
}
|
|
if u.ExpectedCloseDate != nil {
|
|
d.ExpectedCloseDate = u.ExpectedCloseDate
|
|
}
|
|
if u.AssignedTo != nil {
|
|
d.AssignedTo = u.AssignedTo
|
|
}
|
|
if u.Status != nil {
|
|
d.Status = models.DealStatus(*u.Status)
|
|
}
|
|
if u.LostReason != nil {
|
|
d.LostReason = u.LostReason
|
|
}
|
|
}
|
|
|
|
// PushDealDelete deletes the deal in Pipedrive.
|
|
func (s *Service) PushDealDelete(ctx context.Context, orgID, dealID uuid.UUID) *errx.Error {
|
|
return s.pushDelete(ctx, orgID, models.CRMObjectDeal, []uuid.UUID{dealID})
|
|
}
|
|
|
|
func (s *Service) deleteExternal(ctx context.Context, o *org, objectType string, ext int64) error {
|
|
var err error
|
|
switch objectType {
|
|
case models.CRMObjectDeal:
|
|
err = o.Client.DeleteDeal(ctx, ext)
|
|
case models.CRMObjectTask:
|
|
err = o.Client.DeleteActivity(ctx, ext)
|
|
case models.CRMObjectNote:
|
|
err = o.Client.DeleteNote(ctx, ext)
|
|
}
|
|
if ae, ok := AsAPIError(err); ok && ae.NotFound() {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Service) pushDelete(ctx context.Context, orgID uuid.UUID, objectType string, ids []uuid.UUID) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
links, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, objectType, ids)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if len(links) == 0 {
|
|
return nil
|
|
}
|
|
if len(links) > syncBulkLimit {
|
|
var ext []string
|
|
for _, l := range links {
|
|
ext = append(ext, l.ExternalID)
|
|
}
|
|
for start := 0; start < len(ext); start += 100 {
|
|
end := min(start+100, len(ext))
|
|
if err := s.d.Repo.EnqueueJob(ctx, &models.CRMSyncJob{
|
|
OrganizationID: orgID, Provider: provider, Kind: models.CRMJobPushTask,
|
|
Subject: "Delete " + objectType + "s", Payload: map[string]any{"delete": objectType, "external_ids": ext[start:end]},
|
|
}); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
}
|
|
} else {
|
|
for _, l := range links {
|
|
if err := s.deleteExternal(ctx, o, objectType, extID(l.ExternalID)); err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
}
|
|
}
|
|
for localID := range links {
|
|
_ = s.d.Repo.DeleteLinkByLocal(ctx, orgID, provider, objectType, localID)
|
|
}
|
|
s.notify(ctx, orgID, "", objectType)
|
|
return nil
|
|
}
|
|
|
|
// activityBody is a Warmbly task as a Pipedrive activity.
|
|
func (s *Service) activityBody(ctx context.Context, o *org, t *models.CRMTask) map[string]any {
|
|
body := map[string]any{
|
|
"subject": t.Title,
|
|
"type": s.activityKey(ctx, o, t.Type),
|
|
"done": t.Status == models.CRMTaskStatusCompleted || t.Status == models.CRMTaskStatusCancelled,
|
|
}
|
|
if t.Description != nil {
|
|
body["note"] = textToHTML(*t.Description)
|
|
}
|
|
setDue(body, t.DueDate)
|
|
return body
|
|
}
|
|
|
|
// setDue writes a due date, with a time only when the task has one.
|
|
func setDue(body map[string]any, due *time.Time) {
|
|
if due == nil {
|
|
return
|
|
}
|
|
body["due_date"] = pdDate(*due)
|
|
if u := due.UTC(); u.Hour() != 0 || u.Minute() != 0 {
|
|
body["due_time"] = pdClock(u)
|
|
}
|
|
}
|
|
|
|
// PushTaskCreate creates an activity for a task just written locally.
|
|
func (s *Service) PushTaskCreate(ctx context.Context, orgID uuid.UUID, task *models.CRMTask) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
body := s.activityBody(ctx, o, task)
|
|
owner := s.ownerFor(ctx, o, task.AssignedTo)
|
|
if task.ContactID != nil {
|
|
ext, rec, err := s.ensureContact(ctx, o, *task.ContactID)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if ext != "" {
|
|
body["participants"] = []Participant{{PersonID: extID(ext), Primary: true}}
|
|
}
|
|
if rec != nil {
|
|
if rec.CompanyExternalID != "" {
|
|
body["org_id"] = extID(rec.CompanyExternalID)
|
|
}
|
|
if owner == "" {
|
|
owner = rec.OwnerExternalID
|
|
}
|
|
}
|
|
}
|
|
if task.DealID != nil {
|
|
if l, _ := s.d.Repo.GetLinkByLocal(ctx, orgID, provider, models.CRMObjectDeal, *task.DealID); l != nil {
|
|
body["deal_id"] = extID(l.ExternalID)
|
|
}
|
|
}
|
|
if owner != "" {
|
|
body["owner_id"] = extID(owner)
|
|
}
|
|
created, err := o.Client.CreateActivity(ctx, body)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if err := s.d.Repo.ClaimLink(ctx, &models.CRMExternalLink{OrganizationID: orgID, Provider: provider,
|
|
ObjectType: models.CRMObjectTask, LocalID: task.ID, ExternalID: id(created.ID), Meta: ownerMeta(owner)}); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
s.notify(ctx, orgID, contactIDString(task.ContactID), "task")
|
|
return nil
|
|
}
|
|
|
|
// PushTaskUpdate writes a task edit to Pipedrive before it lands locally.
|
|
// Priority stays Warmbly's: Pipedrive activities carry none by default.
|
|
func (s *Service) PushTaskUpdate(ctx context.Context, orgID uuid.UUID, before *models.CRMTask, data *models.UpdateCRMTask) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
link, err := s.d.Repo.GetLinkByLocal(ctx, orgID, provider, models.CRMObjectTask, before.ID)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if link == nil {
|
|
merged := *before
|
|
applyTaskUpdate(&merged, data)
|
|
return s.PushTaskCreate(ctx, orgID, &merged)
|
|
}
|
|
body := map[string]any{}
|
|
if data.Title != nil {
|
|
body["subject"] = *data.Title
|
|
}
|
|
if data.Description != nil {
|
|
body["note"] = textToHTML(*data.Description)
|
|
}
|
|
if data.DueDate != nil {
|
|
setDue(body, data.DueDate)
|
|
}
|
|
if data.Type != nil {
|
|
body["type"] = s.activityKey(ctx, o, *data.Type)
|
|
}
|
|
if data.Status != nil {
|
|
st := models.CRMTaskStatus(*data.Status)
|
|
body["done"] = st == models.CRMTaskStatusCompleted || st == models.CRMTaskStatusCancelled
|
|
}
|
|
if data.AssignedTo != nil {
|
|
owner := s.ownerFor(ctx, o, data.AssignedTo)
|
|
if owner == "" {
|
|
return errx.NewWithIdentifier(errx.BadRequest, "crm_owner_unmapped",
|
|
"That member is not a Pipedrive user yet. Match them to a Pipedrive user in Integrations > Pipedrive.")
|
|
}
|
|
body["owner_id"] = extID(owner)
|
|
}
|
|
if len(body) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := o.Client.UpdateActivity(ctx, extID(link.ExternalID), body); err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
s.notify(ctx, orgID, contactIDString(before.ContactID), "task")
|
|
return nil
|
|
}
|
|
|
|
func applyTaskUpdate(t *models.CRMTask, u *models.UpdateCRMTask) {
|
|
if u.Title != nil {
|
|
t.Title = *u.Title
|
|
}
|
|
if u.Description != nil {
|
|
t.Description = u.Description
|
|
}
|
|
if u.DueDate != nil {
|
|
t.DueDate = u.DueDate
|
|
}
|
|
if u.Priority != nil {
|
|
t.Priority = models.CRMTaskPriority(*u.Priority)
|
|
}
|
|
if u.Type != nil {
|
|
t.Type = *u.Type
|
|
}
|
|
if u.Status != nil {
|
|
t.Status = models.CRMTaskStatus(*u.Status)
|
|
}
|
|
if u.AssignedTo != nil {
|
|
t.AssignedTo = u.AssignedTo
|
|
}
|
|
}
|
|
|
|
// PushTasksBulk marks many activities done or not done. A few are written
|
|
// now; more are queued, each written from its saved state a moment later.
|
|
func (s *Service) PushTasksBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, status, _ *string) *errx.Error {
|
|
if status == nil {
|
|
return nil
|
|
}
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
links, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectTask, ids)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
st := models.CRMTaskStatus(*status)
|
|
done := st == models.CRMTaskStatusCompleted || st == models.CRMTaskStatusCancelled
|
|
if len(links) <= syncBulkLimit {
|
|
for _, l := range links {
|
|
if _, err := o.Client.UpdateActivity(ctx, extID(l.ExternalID), map[string]any{"done": done}); err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
}
|
|
s.notify(ctx, orgID, "", "task")
|
|
return nil
|
|
}
|
|
for localID := range links {
|
|
if err := s.d.Repo.EnqueueJob(ctx, &models.CRMSyncJob{
|
|
OrganizationID: orgID, Provider: provider, Kind: models.CRMJobPushTask,
|
|
DedupeKey: "task-state:" + localID.String(), Subject: "task " + localID.String()[:8],
|
|
Payload: map[string]any{"local_id": localID.String(), "update": true},
|
|
NextAttemptAt: time.Now().Add(5 * time.Second),
|
|
}); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PushTasksDelete deletes activities in Pipedrive.
|
|
func (s *Service) PushTasksDelete(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) *errx.Error {
|
|
return s.pushDelete(ctx, orgID, models.CRMObjectTask, ids)
|
|
}
|
|
|
|
// PushNoteCreate creates a note on the person that was just written locally.
|
|
func (s *Service) PushNoteCreate(ctx context.Context, orgID uuid.UUID, note *models.ContactNote) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
ext, rec, err := s.ensureContact(ctx, o, note.ContactID)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if ext == "" {
|
|
return errx.NewWithIdentifier(errx.NotFound, "crm_contact_missing",
|
|
"This contact is not in Pipedrive, and creating people is turned off in the Pipedrive settings.")
|
|
}
|
|
body := map[string]any{"content": textToHTML(note.Content), "person_id": extID(ext)}
|
|
if rec != nil && rec.CompanyExternalID != "" {
|
|
body["org_id"] = extID(rec.CompanyExternalID)
|
|
}
|
|
created, err := o.Client.CreateNote(ctx, body)
|
|
if err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
if err := s.d.Repo.ClaimLink(ctx, &models.CRMExternalLink{OrganizationID: orgID, Provider: provider,
|
|
ObjectType: models.CRMObjectNote, LocalID: note.ID, ExternalID: id(created.ID)}); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
s.notify(ctx, orgID, note.ContactID.String(), "note")
|
|
return nil
|
|
}
|
|
|
|
// PushNoteUpdate writes an edited note to Pipedrive.
|
|
func (s *Service) PushNoteUpdate(ctx context.Context, orgID, noteID uuid.UUID, content string) *errx.Error {
|
|
o, xerr := s.mustResolve(ctx, orgID)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
link, err := s.d.Repo.GetLinkByLocal(ctx, orgID, provider, models.CRMObjectNote, noteID)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if link == nil {
|
|
return nil
|
|
}
|
|
if err := o.Client.UpdateNote(ctx, extID(link.ExternalID), textToHTML(content)); err != nil {
|
|
return s.userError(ctx, o, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PushNoteDelete deletes a note in Pipedrive.
|
|
func (s *Service) PushNoteDelete(ctx context.Context, orgID, noteID uuid.UUID) *errx.Error {
|
|
return s.pushDelete(ctx, orgID, models.CRMObjectNote, []uuid.UUID{noteID})
|
|
}
|
|
|
|
// EnqueuePush queues a record written outside the dashboard (automations,
|
|
// reply tasks) for Pipedrive. A no-op outside Pipedrive mode.
|
|
func (s *Service) EnqueuePush(ctx context.Context, orgID uuid.UUID, objectType string, localID uuid.UUID) {
|
|
if !s.Active(ctx, orgID) {
|
|
return
|
|
}
|
|
kind := map[string]string{
|
|
models.CRMObjectDeal: models.CRMJobPushDeal,
|
|
models.CRMObjectTask: models.CRMJobPushTask,
|
|
models.CRMObjectNote: models.CRMJobPushNote,
|
|
}[objectType]
|
|
if kind == "" {
|
|
return
|
|
}
|
|
if err := s.d.Repo.EnqueueJob(ctx, &models.CRMSyncJob{
|
|
OrganizationID: orgID, Provider: provider, Kind: kind,
|
|
DedupeKey: kind + ":" + localID.String(),
|
|
Subject: objectType + " " + localID.String()[:8],
|
|
Payload: map[string]any{"local_id": localID.String()},
|
|
}); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID.String()).Msg("pipedrive: could not queue a record")
|
|
}
|
|
}
|
|
|
|
// ---------- read decoration ----------
|
|
|
|
// DecorateDeals attaches Pipedrive links and owner names to deals.
|
|
func (s *Service) DecorateDeals(ctx context.Context, orgID uuid.UUID, deals []models.Deal) {
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil || o == nil || len(deals) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, len(deals))
|
|
for i := range deals {
|
|
ids[i] = deals[i].ID
|
|
}
|
|
links, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectDeal, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
names := s.ownerNames(ctx, orgID)
|
|
for i := range deals {
|
|
l, ok := links[deals[i].ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
ref := &models.CRMExternalRef{Provider: provider, ExternalID: l.ExternalID, URL: o.dealURL(l.ExternalID), SyncedAt: l.SyncedAt}
|
|
if owner, _ := l.Meta["owner"].(string); owner != "" && deals[i].AssignedTo == nil {
|
|
ref.OwnerName = names[owner]
|
|
}
|
|
deals[i].External = ref
|
|
}
|
|
}
|
|
|
|
// DecorateTasks attaches Pipedrive links and owner names to tasks.
|
|
func (s *Service) DecorateTasks(ctx context.Context, orgID uuid.UUID, tasks []models.CRMTask) {
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil || o == nil || len(tasks) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, len(tasks))
|
|
var dealIDs []uuid.UUID
|
|
for i := range tasks {
|
|
ids[i] = tasks[i].ID
|
|
if tasks[i].DealID != nil {
|
|
dealIDs = append(dealIDs, *tasks[i].DealID)
|
|
}
|
|
}
|
|
links, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectTask, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
dealLinks, _ := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectDeal, dealIDs)
|
|
names := s.ownerNames(ctx, orgID)
|
|
for i := range tasks {
|
|
l, ok := links[tasks[i].ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
url := o.activitiesURL()
|
|
if tasks[i].DealID != nil {
|
|
if dl, ok := dealLinks[*tasks[i].DealID]; ok {
|
|
url = o.dealURL(dl.ExternalID)
|
|
}
|
|
}
|
|
ref := &models.CRMExternalRef{Provider: provider, ExternalID: l.ExternalID, URL: url, SyncedAt: l.SyncedAt}
|
|
if owner, _ := l.Meta["owner"].(string); owner != "" && tasks[i].AssignedTo == nil {
|
|
ref.OwnerName = names[owner]
|
|
}
|
|
tasks[i].External = ref
|
|
}
|
|
}
|
|
|
|
// DecorateNotes attaches Pipedrive links to notes.
|
|
func (s *Service) DecorateNotes(ctx context.Context, orgID uuid.UUID, notes []models.ContactNote) {
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil || o == nil || len(notes) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, len(notes))
|
|
for i := range notes {
|
|
ids[i] = notes[i].ID
|
|
}
|
|
links, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectNote, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var personURL string
|
|
if rec, _ := s.d.Repo.GetContactRecord(ctx, orgID, notes[0].ContactID, provider); rec != nil {
|
|
personURL = o.personURL(rec.ExternalID)
|
|
}
|
|
for i := range notes {
|
|
if l, ok := links[notes[i].ID]; ok {
|
|
notes[i].External = &models.CRMExternalRef{Provider: provider, ExternalID: l.ExternalID, URL: personURL, SyncedAt: l.SyncedAt}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DecoratePipelines marks Pipedrive pipelines and their stages' probability.
|
|
func (s *Service) DecoratePipelines(ctx context.Context, orgID uuid.UUID, pipes []models.Pipeline) {
|
|
o, err := s.resolve(ctx, orgID)
|
|
if err != nil || o == nil || len(pipes) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, 0, len(pipes))
|
|
var stageIDs []uuid.UUID
|
|
for _, p := range pipes {
|
|
ids = append(ids, p.ID)
|
|
for _, st := range p.Stages {
|
|
stageIDs = append(stageIDs, st.ID)
|
|
}
|
|
}
|
|
plinks, err := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectPipeline, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
slinks, _ := s.d.Repo.LinksForLocal(ctx, orgID, provider, models.CRMObjectStage, stageIDs)
|
|
for i := range pipes {
|
|
if l, ok := plinks[pipes[i].ID]; ok {
|
|
pipes[i].External = &models.CRMExternalRef{Provider: provider, ExternalID: l.ExternalID, URL: o.pipelineURL(l.ExternalID), SyncedAt: l.SyncedAt}
|
|
}
|
|
for j := range pipes[i].Stages {
|
|
if l, ok := slinks[pipes[i].Stages[j].ID]; ok {
|
|
pipes[i].Stages[j].Probability = probabilityOf(l.Meta)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) ownerNames(ctx context.Context, orgID uuid.UUID) map[string]string {
|
|
out := map[string]string{}
|
|
owners, err := s.d.Repo.ListOwners(ctx, orgID, provider)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, o := range owners {
|
|
out[o.ExternalID] = o.DisplayName()
|
|
}
|
|
return out
|
|
}
|