package pipedrive import ( "context" "fmt" "html" "slices" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/models" ) // OnEvent is the platform event sink: it turns sends, replies, bounces, // unsubscribes, opens, clicks and bookings into outbox jobs. It runs before // the webhook throttle, so Pipedrive sees every send even when a burst is too // large for webhook fan-out. func (s *Service) OnEvent(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) { m, ok := data.(map[string]any) if !ok || orgID == uuid.Nil || !s.Active(ctx, orgID) { return } row, err := s.settingsRow(ctx, orgID) if err != nil || row == nil { return } act := row.Config.Activity job := &models.CRMSyncJob{OrganizationID: orgID, Provider: provider, Payload: map[string]any{}} copyKeys(job.Payload, m, "contact_id", "contact_email", "campaign_id", "intent", "reason", "subject") email := str(m, "contact_email") job.Subject = email switch eventType { case models.WebhookEventCampaignEmailSent: job.Kind = models.CRMJobLogEmail job.Payload["direction"] = "out" copyKeys(job.Payload, m, "from_email") job.Payload["subject"] = str(m, "_subject") job.Payload["body"] = str(m, "_body_text") job.Payload["task_id"] = str(m, "_task_id") job.Payload["email_account_id"] = str(m, "_email_account_id") job.Payload["at"] = time.Now().UTC().Format(time.RFC3339) if t := str(m, "_task_id"); t != "" { job.DedupeKey = "email:" + t } if !act.Sent && !row.Config.WriteProperties { return } case models.WebhookEventCampaignReplyReceived: job.Kind = models.CRMJobLogEmail job.Payload["direction"] = "in" job.Payload["body"] = firstNonEmpty(str(m, "_body_text"), str(m, "snippet")) job.Payload["email_account_id"] = str(m, "email_account_id") job.Payload["from_email"] = email job.Payload["to_email"] = str(m, "_mailbox_email") job.Payload["at"] = time.Now().UTC().Format(time.RFC3339) if mid := str(m, "_message_id"); mid != "" { job.DedupeKey = "reply:" + mid } case models.WebhookEventCampaignEmailBounced: job.Kind = models.CRMJobLogEvent job.Payload["event"] = "bounce" job.Payload["task_id"] = str(m, "_task_id") job.DedupeKey = "bounce:" + email + ":" + str(m, "campaign_id") case models.WebhookEventCampaignUnsubscribed: job.Kind = models.CRMJobLogEvent job.Payload["event"] = "unsubscribe" job.DedupeKey = "unsub:" + email case models.WebhookEventCampaignEmailOpened, models.WebhookEventCampaignEmailClicked: if !row.Config.WriteProperties || (eventType == models.WebhookEventCampaignEmailOpened && !act.Opens) || (eventType == models.WebhookEventCampaignEmailClicked && !act.Clicks) { return } job.Kind = models.CRMJobLogEvent job.Payload["event"] = "open" if eventType == models.WebhookEventCampaignEmailClicked { job.Payload["event"] = "click" } job.Payload["at"] = time.Now().UTC().Format(time.RFC3339) // "Last opened" is a date in Pipedrive: one write per contact per day. job.DedupeKey = fmt.Sprintf("%s:%s:%s", job.Payload["event"], email, time.Now().UTC().Format("2006-01-02")) case models.WebhookEventContactUpdated: // A Warmbly edit is the most recent change: two-way and push fields go // to Pipedrive. Pipedrive's own edits arrive through the pull, which // writes the contact without emitting this event, so nothing loops. if str(m, "entity_type") != string(models.AuditEntityContact) { return } if md, ok := m["metadata"].(map[string]string); ok && md["crm"] != "" { return } cid := str(m, "entity_id") if _, err := uuid.Parse(cid); err != nil { return } job.Kind = models.CRMJobPushContact job.Payload = map[string]any{"contact_id": cid} job.Subject = "Contact " + cid[:8] job.DedupeKey = "contact:" + cid job.NextAttemptAt = time.Now().Add(10 * time.Second) case models.WebhookEventMeetingBooked: if !act.Meetings { return } job.Kind = models.CRMJobLogMeeting copyKeys(job.Payload, m, "event_name", "scheduled_for", "invitee_email", "invitee_name", "source", "external_event_id") if email == "" { job.Subject = str(m, "invitee_email") job.Payload["contact_email"] = str(m, "invitee_email") } job.DedupeKey = "meeting:" + str(m, "source") + ":" + str(m, "external_event_id") default: return } if job.Subject == "" { job.Subject = job.Kind } if err := s.d.Repo.EnqueueJob(ctx, job); err != nil { log.Warn().Err(err).Str("org_id", orgID.String()).Str("kind", job.Kind).Msg("pipedrive: could not queue activity") } } func copyKeys(dst, src map[string]any, keys ...string) { for _, k := range keys { if v, ok := src[k]; ok && v != nil && fmt.Sprint(v) != "" { dst[k] = v } } } func str(m map[string]any, k string) string { v, ok := m[k] if !ok || v == nil { return "" } if s, ok := v.(string); ok { return strings.TrimSpace(s) } return strings.TrimSpace(fmt.Sprint(v)) } // mailboxOwner is the Pipedrive user who owns the sending mailbox. func (s *Service) mailboxOwner(ctx context.Context, o *org, p map[string]any) int64 { acct, err := uuid.Parse(str(p, "email_account_id")) if err != nil { return 0 } uid, _ := s.d.Repo.MailboxUser(ctx, o.ID, acct) return extID(s.ownerFor(ctx, o, uid)) } // jobContact resolves the Warmbly contact a job is about. create allows a // Pipedrive person to be created for them (sends and replies, never opens). func (s *Service) jobContact(ctx context.Context, o *org, p map[string]any, create bool) (uuid.UUID, string, *models.CRMContactRecord, error) { var contactID uuid.UUID if cid, err := uuid.Parse(str(p, "contact_id")); err == nil { contactID = cid } else if email := str(p, "contact_email"); email != "" { c, xerr := s.d.Contacts.GetByEmailAndOrganization(ctx, o.ID, email) if xerr != nil || c == nil { return uuid.Nil, "", nil, nil } contactID = c.ID } else { return uuid.Nil, "", nil, nil } if !create { rec, err := s.d.Repo.GetContactRecord(ctx, o.ID, contactID, provider) if err != nil || rec == nil { return contactID, "", nil, err } return contactID, rec.ExternalID, rec, nil } ext, rec, err := s.ensureContactOwned(ctx, o, contactID, s.mailboxOwner(ctx, o, p)) return contactID, ext, rec, err } // openDeal is the contact's first open deal in Pipedrive, or 0. func (s *Service) openDeal(ctx context.Context, o *org, contactID uuid.UUID) int64 { deals, err := s.d.CRM.GetDealsByContact(ctx, o.ID, contactID) if err != nil { return 0 } var ids []uuid.UUID for _, d := range deals { if d.Status == models.DealStatusOpen { ids = append(ids, d.ID) } } links, err := s.d.Repo.LinksForLocal(ctx, o.ID, provider, models.CRMObjectDeal, ids) if err != nil { return 0 } for _, did := range ids { if l, ok := links[did]; ok { return extID(l.ExternalID) } } return 0 } // logEmail writes a send or a reply as a done Email activity on the person, // their organization and their open deal, and updates the Warmbly fields. // // A retry never logs the same email twice: the logged activity is linked to // the send's task (or, for a reply, to this job) before anything else can fail. func (s *Service) logEmail(ctx context.Context, o *org, job *models.CRMSyncJob) error { p := job.Payload contactID, ext, rec, err := s.jobContact(ctx, o, p, true) if err != nil || ext == "" { return err } logKey := job.ID inbound := str(p, "direction") == "in" if taskID, perr := uuid.Parse(str(p, "task_id")); perr == nil && !inbound { logKey = taskID } at := parseTime(str(p, "at")) if at == nil { at = ptrNow() } owner := s.mailboxOwner(ctx, o, p) if owner == 0 && rec != nil { owner = extID(rec.OwnerExternalID) } logIt := (inbound && o.Config.Activity.Replies) || (!inbound && o.Config.Activity.Sent) if logIt { if done, err := s.d.Repo.GetLinkByLocal(ctx, o.ID, provider, models.CRMObjectEmail, logKey); err != nil { return err } else if done != nil { logIt = false } } campaign := "" if cid, err := uuid.Parse(str(p, "campaign_id")); err == nil { campaign, _ = s.d.Repo.CampaignName(ctx, o.ID, cid) } if logIt { subject := firstNonEmpty(str(p, "subject"), "(no subject)") contactEmail := str(p, "contact_email") from, to := str(p, "from_email"), contactEmail title := subject if inbound { from, to = contactEmail, str(p, "to_email") title = "Reply: " + subject } var head strings.Builder head.WriteString("

From: " + html.EscapeString(from) + "
To: " + html.EscapeString(to)) if campaign != "" { head.WriteString("
Campaign: " + html.EscapeString(campaign)) } head.WriteString("
Logged by Warmbly

") body := map[string]any{ "subject": truncateRunes(title, 250), "type": "email", "done": true, "due_date": pdDate(*at), "due_time": pdClock(*at), "note": head.String() + "

" + textToHTML(truncateRunes(str(p, "body"), 60000)) + "

", "participants": []Participant{{PersonID: extID(ext), Primary: true}}, } if owner != 0 { body["owner_id"] = owner } if rec != nil && rec.CompanyExternalID != "" { body["org_id"] = extID(rec.CompanyExternalID) } if deal := s.openDeal(ctx, o, contactID); deal != 0 { body["deal_id"] = deal } created, err := o.Client.CreateActivity(ctx, body) if err != nil { return err } if err := s.d.Repo.UpsertLink(ctx, &models.CRMExternalLink{OrganizationID: o.ID, Provider: provider, ObjectType: models.CRMObjectEmail, LocalID: logKey, ExternalID: id(created.ID), Meta: map[string]any{"subject": truncateRunes(title, 250)}}); err != nil { return err } } if o.Config.WriteProperties { values := map[string]string{} if link := s.contactLink(contactID); link != "" { values[fieldLink] = link } if inbound { values[fieldLastReplied] = pdDate(*at) intent := str(p, "intent") values[fieldReplyIntent] = intent values[fieldStatus] = statusReplied switch models.ReplyIntentType(intent) { case models.ReplyIntentPositive: values[fieldStatus] = statusInterested case models.ReplyIntentNegative: values[fieldStatus] = statusNotInterest } } else { values[fieldLastContacted] = pdDate(*at) if campaign != "" { values[fieldLastCampaign] = campaign } if cur := currentStatus(rec); cur == "" || cur == statusContacted { values[fieldStatus] = statusContacted } } body := map[string]any{} if custom := s.warmblyValues(ctx, o, values); len(custom) > 0 { body["custom_fields"] = custom } if !inbound { if contacts, xerr := s.d.Contacts.GetByIDsAndOrganization(ctx, o.ID, []uuid.UUID{contactID}); xerr == nil && len(contacts) == 1 { for k, v := range s.pushOnlyBody(ctx, o, &contacts[0]) { if k == "custom_fields" { custom, _ := body["custom_fields"].(map[string]any) if custom == nil { custom = map[string]any{} } for ck, cv := range v.(map[string]any) { custom[ck] = cv } body["custom_fields"] = custom continue } body[k] = v } } } if len(body) > 0 { if _, err := o.Client.UpdatePerson(ctx, extID(ext), body); err != nil { return err } } s.rememberStatus(ctx, o, contactID, values[fieldStatus]) } if inbound && models.ReplyIntentType(str(p, "intent")) == models.ReplyIntentPositive { // Its own job, so a failure there retries the outcome and not the email. return s.d.Repo.EnqueueJob(ctx, &models.CRMSyncJob{ OrganizationID: o.ID, Provider: provider, Kind: models.CRMJobReplyOutcome, DedupeKey: "outcome:" + job.ID.String(), Subject: job.Subject, Payload: map[string]any{"contact_id": contactID.String(), "campaign_id": str(p, "campaign_id"), "email_account_id": str(p, "email_account_id")}, }) } s.notify(ctx, o.ID, contactID.String(), "contact") return nil } func currentStatus(rec *models.CRMContactRecord) string { if rec == nil { return "" } return rec.Properties[statusKey] } // rememberStatus keeps the last written Warmbly status on the record, so a // follow-up send never turns "Interested" back into "Contacted". func (s *Service) rememberStatus(ctx context.Context, o *org, contactID uuid.UUID, status string) { if status == "" { return } rec, err := s.d.Repo.GetContactRecord(ctx, o.ID, contactID, provider) if err != nil || rec == nil { return } if rec.Properties == nil { rec.Properties = map[string]string{} } rec.Properties[statusKey] = status _ = s.d.Repo.UpsertContactRecord(ctx, rec) } // pushOnlyBody is the mapped fields Warmbly owns outright. func (s *Service) pushOnlyBody(ctx context.Context, o *org, c *models.Contact) map[string]any { narrowed := *o narrowed.Config.FieldMap = map[string]string{} for field, key := range o.Config.FieldMap { if o.Config.FieldDirection[field] == models.CRMFieldPush { narrowed.Config.FieldMap[field] = key } } if len(narrowed.Config.FieldMap) == 0 { return nil } return s.personBody(ctx, &narrowed, c, false) } // replyOutcomeJob runs the interested-reply outcome for a contact in Pipedrive. func (s *Service) replyOutcomeJob(ctx context.Context, o *org, p map[string]any) error { contactID, ext, rec, err := s.jobContact(ctx, o, p, false) if err != nil || ext == "" { return err } return s.replyOutcome(ctx, o, contactID, ext, rec, p) } // replyOutcome is what an interested reply does in Pipedrive: a label on the // person, a lead in the Leads Inbox, and optionally a deal. func (s *Service) replyOutcome(ctx context.Context, o *org, contactID uuid.UUID, ext string, rec *models.CRMContactRecord, p map[string]any) error { cfg := o.Config.PositiveReply if label := extID(cfg.LifecycleStage); label != 0 { // Labels are a set: the person keeps the ones they already have. current := []string{} if rec != nil { current = splitIDs(rec.Properties["label_ids"]) } if !slices.Contains(current, id(label)) { labels := append(toIDs(current), label) if _, err := o.Client.UpdatePerson(ctx, extID(ext), map[string]any{"label_ids": labels}); err != nil { return err } } } owner := s.mailboxOwner(ctx, o, p) hasOpenDeal := s.openDeal(ctx, o, contactID) != 0 contacts, xerr := s.d.Contacts.GetByIDsAndOrganization(ctx, o.ID, []uuid.UUID{contactID}) if xerr != nil || len(contacts) == 0 { return s.afterOutcome(ctx, o, contactID) } c := contacts[0] name := strings.TrimSpace(firstNonEmpty(c.Company, strings.TrimSpace(c.FirstName+" "+c.LastName), c.Email)) if cfg.CreateLead && !hasOpenDeal { lead := map[string]any{"title": truncateRunes(name, 250), "person_id": extID(ext)} if rec != nil && rec.CompanyExternalID != "" { lead["organization_id"] = extID(rec.CompanyExternalID) } if owner != 0 { lead["owner_id"] = owner } // Pipedrive has no idempotency key for leads: a retried outcome skips a // lead it already made. key := "pipedrive:lead:" + contactID.String() if !s.marked(ctx, key) { err := o.Client.CreateLead(ctx, lead) if ae, ok := AsAPIError(err); ok && ae.AuthProblem() { // The app or the connected user may not add leads; the rest of // the outcome still runs. log.Info().Str("org_id", o.ID.String()).Msg("pipedrive: no permission to add a lead") err = nil } if err != nil { return err } s.mark(ctx, key, 24*time.Hour) } } if cfg.CreateDeal && cfg.DealPipelineID != nil && cfg.DealStageID != nil && !hasOpenDeal { create := &models.CreateDeal{PipelineID: *cfg.DealPipelineID, StageID: *cfg.DealStageID, ContactID: &contactID, Name: name} if cid, err := uuid.Parse(str(p, "campaign_id")); err == nil { create.CampaignID = &cid } if acct, err := uuid.Parse(str(p, "email_account_id")); err == nil { create.SourceMailboxID = &acct if uid, _ := s.d.Repo.MailboxUser(ctx, o.ID, acct); uid != nil { create.AssignedTo = uid } } deal, err := s.d.CRM.CreateDeal(ctx, o.ID, create) if err != nil { return err } if xerr := s.PushDealCreate(ctx, o.ID, deal); xerr != nil { _ = s.d.CRM.DeleteDeal(ctx, o.ID, deal.ID) return xerr } } return s.afterOutcome(ctx, o, contactID) } func (s *Service) afterOutcome(ctx context.Context, o *org, contactID uuid.UUID) error { if rec, _ := s.d.Repo.GetContactRecord(ctx, o.ID, contactID, provider); rec != nil { _ = s.refreshContact(ctx, o, contactID, rec.ExternalID, false) } return nil } // logEvent records a bounce, unsubscribe, open or click on a person Pipedrive // already has. Nobody is created in Pipedrive for an open. func (s *Service) logEvent(ctx context.Context, o *org, job *models.CRMSyncJob) error { p := job.Payload contactID, ext, rec, err := s.jobContact(ctx, o, p, false) if err != nil || ext == "" { return err } at := time.Now() if t := parseTime(str(p, "at")); t != nil { at = *t } values := map[string]string{} switch str(p, "event") { case "bounce": values[fieldStatus] = statusBounced if taskID, perr := uuid.Parse(str(p, "task_id")); perr == nil && o.Config.Activity.Bounces { if l, _ := s.d.Repo.GetLinkByLocal(ctx, o.ID, provider, models.CRMObjectEmail, taskID); l != nil { subject, _ := l.Meta["subject"].(string) _, _ = o.Client.UpdateActivity(ctx, extID(l.ExternalID), map[string]any{ "subject": truncateRunes("Bounced: "+firstNonEmpty(subject, "email"), 250)}) } } case "unsubscribe": values[fieldStatus] = statusUnsubscribed if o.Config.Activity.Unsubscribes { note := map[string]any{"content": "Unsubscribed from Warmbly outreach.", "person_id": extID(ext)} if rec != nil && rec.CompanyExternalID != "" { note["org_id"] = extID(rec.CompanyExternalID) } if err := s.createOnce(ctx, o, job, models.CRMObjectEmail, func() (int64, error) { n, err := o.Client.CreateNote(ctx, note) return n.ID, err }); err != nil { return err } } case "open": values[fieldLastOpened] = pdDate(at) case "click": values[fieldLastClicked] = pdDate(at) } custom := s.warmblyValues(ctx, o, values) if len(custom) == 0 { return nil } if _, err := o.Client.UpdatePerson(ctx, extID(ext), map[string]any{"custom_fields": custom}); err != nil { return err } s.rememberStatus(ctx, o, contactID, values[fieldStatus]) return nil } // logMeeting records a Calendly or Cal.com booking as a Meeting activity. func (s *Service) logMeeting(ctx context.Context, o *org, job *models.CRMSyncJob) error { p := job.Payload contactID, ext, rec, err := s.jobContact(ctx, o, p, true) if err != nil || ext == "" { return err } start := parseTime(str(p, "scheduled_for")) if start == nil { start = ptrNow() } body := map[string]any{ "subject": truncateRunes(firstNonEmpty(str(p, "event_name"), "Meeting"), 250), "type": "meeting", "done": false, "due_date": pdDate(*start), "due_time": pdClock(*start), "duration": "00:30", "note": html.EscapeString("Booked through " + firstNonEmpty(str(p, "source"), "a scheduling link") + "."), "participants": []Participant{{PersonID: extID(ext), Primary: true}}, } if rec != nil { if rec.OwnerExternalID != "" { body["owner_id"] = extID(rec.OwnerExternalID) } if rec.CompanyExternalID != "" { body["org_id"] = extID(rec.CompanyExternalID) } } if deal := s.openDeal(ctx, o, contactID); deal != 0 { body["deal_id"] = deal } if err := s.createOnce(ctx, o, job, models.CRMObjectMeeting, func() (int64, error) { a, err := o.Client.CreateActivity(ctx, body) return a.ID, err }); err != nil { return err } if custom := s.warmblyValues(ctx, o, map[string]string{fieldStatus: statusMeeting}); len(custom) > 0 { if _, err := o.Client.UpdatePerson(ctx, extID(ext), map[string]any{"custom_fields": custom}); err != nil { return err } s.rememberStatus(ctx, o, contactID, statusMeeting) } return nil } // createOnce creates a Pipedrive record for a job at most once: the record is // linked to the job before any later step can fail and retry it. func (s *Service) createOnce(ctx context.Context, o *org, job *models.CRMSyncJob, objectType string, create func() (int64, error)) error { if done, err := s.d.Repo.GetLinkByLocal(ctx, o.ID, provider, objectType, job.ID); err != nil || done != nil { return err } created, err := create() if err != nil { return err } return s.d.Repo.UpsertLink(ctx, &models.CRMExternalLink{OrganizationID: o.ID, Provider: provider, ObjectType: objectType, LocalID: job.ID, ExternalID: id(created)}) } // pushContactFields writes a Warmbly contact's two-way and push fields to its // linked person. Contacts not in Pipedrive are left alone. func (s *Service) pushContactFields(ctx context.Context, o *org, p map[string]any) error { contactID, err := uuid.Parse(str(p, "contact_id")) if err != nil { return nil } rec, err := s.d.Repo.GetContactRecord(ctx, o.ID, contactID, provider) if err != nil || rec == nil { return err } contacts, xerr := s.d.Contacts.GetByIDsAndOrganization(ctx, o.ID, []uuid.UUID{contactID}) if xerr != nil { return xerr } if len(contacts) != 1 { return nil } body := s.personBody(ctx, o, &contacts[0], false) if len(body) == 0 { return nil } _, err = o.Client.UpdatePerson(ctx, extID(rec.ExternalID), body) return err }