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

477 lines
15 KiB
Go

package pipedrive
import (
"context"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Inside Pipedrive: a JSON panel on person and deal pages showing the
// person's Warmbly outreach, and JSON modals that add them to a campaign or
// pause and resume them. Pipedrive signs every call with a JWT over the app's
// client secret; its company picks the workspace and its user the member.
// Leads enrolls contacts in campaigns and reads their campaign state (the
// contact service). Nil disables the in-Pipedrive actions.
type Leads interface {
Add(ctx context.Context, userID string, orgID uuid.UUID, contacts []models.AddContact) ([]models.Contact, *errx.Error)
BulkUpdate(ctx context.Context, userID string, orgID uuid.UUID, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error)
CampaignStates(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactCampaignState, *errx.Error)
}
// AppCall is one panel or modal request: who Pipedrive says is asking, and
// about which record.
type AppCall struct {
CompanyID string
UserID string
Resource string
RecordID string
}
// appTokenMaxAge bounds how old an issued-at panel or modal token may be.
const appTokenMaxAge = 15 * time.Minute
// VerifyAppToken checks the JWT Pipedrive passes as ?token=: HS256 over the
// client secret, naming the same user and company as the query.
func (s *Service) VerifyAppToken(token string, call AppCall) bool {
if s.d.ClientSecret == "" || token == "" || call.CompanyID == "" || call.UserID == "" {
return false
}
claims := jwt.MapClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(*jwt.Token) (any, error) {
return []byte(s.d.ClientSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !parsed.Valid {
return false
}
// Pipedrive's panel tokens may carry no expiry; one that says when it was
// issued is refused once stale, so a token copied out of a URL ages out.
if iat, err := claims.GetIssuedAt(); err == nil && iat != nil && time.Since(iat.Time) > appTokenMaxAge {
return false
}
return claimID(claims["userId"]) == call.UserID && claimID(claims["companyId"]) == call.CompanyID
}
func claimID(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
}
return ""
}
// companyOrg resolves the one workspace running Pipedrive mode for a company.
func (s *Service) companyOrg(ctx context.Context, companyID string) (*org, *errx.Error) {
ids, err := s.d.Repo.OrgsForCompany(ctx, provider, strings.TrimSpace(companyID))
if err != nil {
return nil, errx.InternalError()
}
if len(ids) == 0 {
return nil, errx.NewWithIdentifier(errx.NotFound, "crm_not_connected",
"This Pipedrive company is not connected to a Warmbly workspace in Pipedrive mode.")
}
// A company must name exactly one workspace, or nothing is chosen for it.
if len(ids) > 1 {
return nil, errx.New(errx.Conflict,
"This Pipedrive company is connected to more than one Warmbly workspace in Pipedrive mode. Keep Pipedrive mode on in one of them.")
}
o, err := s.resolve(ctx, ids[0])
if err != nil || o == nil {
return nil, errx.NewWithIdentifier(errx.NotFound, "crm_not_connected", "Pipedrive mode is off for this workspace.")
}
return o, nil
}
// appMember is the member an app call acts as: the one matched to the
// Pipedrive user, holding every permission in perm.
func (s *Service) appMember(ctx context.Context, o *org, userID string, perm models.OrganizationPermission) (string, *errx.Error) {
member, perms, err := s.d.Repo.MemberForOwner(ctx, o.ID, provider, userID)
if err != nil {
return "", errx.InternalError()
}
if member == nil {
return "", errx.New(errx.Forbidden,
"Your Pipedrive user is not matched to a Warmbly member. Ask a workspace admin to match you under Users on the Pipedrive page in Warmbly.")
}
if !perms.HasPermission(perm) {
return "", errx.New(errx.Forbidden, "Your Warmbly role does not allow this. Ask a workspace admin for access.")
}
return member.String(), nil
}
// appPerson is the Pipedrive person a call is about: the record itself, or a
// deal's person.
func (s *Service) appPerson(ctx context.Context, o *org, call AppCall) (int64, error) {
n := extID(call.RecordID)
if n == 0 {
return 0, nil
}
if call.Resource != "deal" {
return n, nil
}
d, err := o.Client.GetDeal(ctx, n)
if err != nil || d.PersonID == nil {
return 0, err
}
return *d.PersonID, nil
}
// appContact finds the Warmbly contact behind a Pipedrive person, linking it
// by email when Warmbly has the address. person is read when it had to be.
func (s *Service) appContact(ctx context.Context, o *org, personID int64) (*models.Contact, *Person, error) {
if rec, err := s.d.Repo.GetContactRecordByExternal(ctx, o.ID, provider, id(personID)); err != nil {
return nil, nil, err
} else if rec != nil {
cs, xerr := s.d.Contacts.GetByIDsAndOrganization(ctx, o.ID, []uuid.UUID{rec.ContactID})
if xerr != nil {
return nil, nil, xerr
}
if len(cs) == 1 {
return &cs[0], nil, nil
}
}
p, err := o.Client.GetPerson(ctx, personID)
if err != nil {
return nil, nil, err
}
if email := p.Email(); email != "" {
if c, xerr := s.d.Contacts.GetByEmailAndOrganization(ctx, o.ID, email); xerr == nil && c != nil {
if _, _, err := s.storeContactRecord(ctx, o, c.ID, p); err != nil {
return nil, nil, err
}
return c, p, nil
}
}
return nil, p, nil
}
// PanelLabel is a coloured label field in a JSON panel.
type PanelLabel struct {
Color string `json:"color"`
Label string `json:"label"`
}
// PanelLink is a link field in a JSON panel.
type PanelLink struct {
Label string `json:"label"`
Value string `json:"value"`
External bool `json:"external"`
}
// PanelRow is one object of the panel, in the shape its schema declares
// (pipedrive-app/panel.schema.json).
type PanelRow struct {
ID int `json:"id"`
Header string `json:"header"`
Status *PanelLabel `json:"status,omitempty"`
Progress string `json:"progress,omitempty"`
Note string `json:"note,omitempty"`
LastSeen string `json:"last_activity,omitempty"`
Open *PanelLink `json:"open,omitempty"`
}
// Panel is the JSON panel answer.
type Panel struct {
Data []PanelRow `json:"data"`
ExternalLink *PanelExternalLink `json:"external_link,omitempty"`
}
// PanelExternalLink is the panel's "Open in Warmbly".
type PanelExternalLink struct {
URL string `json:"url"`
Label string `json:"label"`
}
var leadColors = map[string]PanelLabel{
models.LeadStatusPending: {Color: "grey", Label: "Waiting"},
models.LeadStatusActive: {Color: "blue", Label: "Active"},
models.LeadStatusCompleted: {Color: "grey", Label: "Finished"},
models.LeadStatusReplied: {Color: "green", Label: "Replied"},
models.LeadStatusBounced: {Color: "red", Label: "Bounced"},
models.LeadStatusFailed: {Color: "red", Label: "Failed"},
models.LeadStatusUnsubscribed: {Color: "red", Label: "Unsubscribed"},
models.LeadStatusPaused: {Color: "yellow", Label: "Paused"},
models.LeadStatusUndeliverable: {Color: "red", Label: "Undeliverable"},
}
var statusColors = map[string]string{
statusContacted: "blue", statusReplied: "purple", statusInterested: "green", statusNotInterest: "grey",
statusMeeting: "green", statusBounced: "red", statusUnsubscribed: "red",
}
// AppPanel builds the panel for a person or deal page.
func (s *Service) AppPanel(ctx context.Context, call AppCall) (*Panel, *errx.Error) {
o, xerr := s.companyOrg(ctx, call.CompanyID)
if xerr != nil {
return nil, xerr
}
if _, xerr := s.appMember(ctx, o, call.UserID, models.PermViewCampaigns|models.PermViewContacts); xerr != nil {
return nil, xerr
}
out := &Panel{Data: []PanelRow{}}
personID, err := s.appPerson(ctx, o, call)
if err != nil {
return nil, s.userError(ctx, o, err)
}
if personID == 0 {
return out, nil
}
c, _, err := s.appContact(ctx, o, personID)
if err != nil {
return nil, s.userError(ctx, o, err)
}
if c == nil {
return out, nil
}
link := s.contactLink(c.ID)
if link != "" {
out.ExternalLink = &PanelExternalLink{URL: link, Label: "Open in Warmbly"}
}
var states []models.ContactCampaignState
if s.d.Leads != nil {
states, _ = s.d.Leads.CampaignStates(ctx, o.ID, c.ID)
}
status := "Not contacted"
if rec, _ := s.d.Repo.GetContactRecord(ctx, o.ID, c.ID, provider); rec != nil && rec.Properties[statusKey] != "" {
status = rec.Properties[statusKey]
}
summary := PanelRow{
ID: 1, Header: "Warmbly",
Status: &PanelLabel{Color: firstNonEmpty(statusColors[status], "grey"), Label: status},
Progress: campaignCount(len(states)),
}
if link != "" {
summary.Open = &PanelLink{Label: "Open in Warmbly", Value: link, External: true}
}
out.Data = append(out.Data, summary)
for i, st := range states {
if len(out.Data) == 10 {
break
}
row := PanelRow{ID: i + 2, Header: truncateRunes(st.CampaignName, 120)}
label, ok := leadColors[st.LeadStatus]
if !ok {
label = PanelLabel{Color: "grey", Label: firstNonEmpty(st.LeadStatus, "Waiting")}
}
if st.Hold != nil {
label = PanelLabel{Color: "yellow", Label: "Paused"}
row.Note = truncateRunes(st.Hold.Reason, 250)
}
row.Status = &label
if total := st.TotalSteps; total > 0 {
row.Progress = stepLabel(st.CompletedSteps, total)
}
if st.LastActionAt != nil {
row.LastSeen = pdTime(*st.LastActionAt)
}
if s.d.AppURL != "" {
row.Open = &PanelLink{Label: "Open campaign", External: true,
Value: strings.TrimRight(s.d.AppURL, "/") + "/app/campaigns/" + st.CampaignID.String()}
}
out.Data = append(out.Data, row)
}
return out, nil
}
func campaignCount(n int) string {
switch n {
case 0:
return "In no campaign"
case 1:
return "In 1 campaign"
default:
return "In " + strconv.Itoa(n) + " campaigns"
}
}
func stepLabel(done, total int) string {
if done >= total {
return "All steps sent"
}
return "Step " + strconv.Itoa(done+1) + " of " + strconv.Itoa(total)
}
// ModalItem is one choice in a JSON modal select.
type ModalItem struct {
Label string `json:"label"`
Value string `json:"value"`
}
// EnrollChoices are the campaigns the enroll modal offers.
func (s *Service) EnrollChoices(ctx context.Context, call AppCall) ([]ModalItem, *errx.Error) {
o, xerr := s.companyOrg(ctx, call.CompanyID)
if xerr != nil {
return nil, xerr
}
if _, xerr := s.appMember(ctx, o, call.UserID, models.PermManageCampaigns|models.PermManageContacts); xerr != nil {
return nil, xerr
}
opts, err := s.d.Repo.CampaignOptions(ctx, o.ID, "", 100)
if err != nil {
return nil, errx.InternalError()
}
out := make([]ModalItem, 0, len(opts))
for _, c := range opts {
label := c.Name
if c.Status != "active" {
label += " (" + strings.ReplaceAll(c.Status, "_", " ") + ")"
}
out = append(out, ModalItem{Label: truncateRunes(label, 120), Value: c.ID.String()})
}
return out, nil
}
// AppEnroll adds the person to a campaign as the matched member, creating the
// Warmbly contact from the Pipedrive person when it is new. It returns the
// campaign's name for the confirmation.
func (s *Service) AppEnroll(ctx context.Context, call AppCall, campaignID string) (string, *errx.Error) {
o, xerr := s.companyOrg(ctx, call.CompanyID)
if xerr != nil {
return "", xerr
}
actor, xerr := s.appMember(ctx, o, call.UserID, models.PermManageCampaigns|models.PermManageContacts)
if xerr != nil {
return "", xerr
}
if s.d.Leads == nil {
return "", errx.New(errx.NotImplemented, "enrolment from Pipedrive is not available on this instance")
}
cid, err := uuid.Parse(strings.TrimSpace(campaignID))
if err != nil {
return "", errx.New(errx.BadRequest, "Choose a campaign.")
}
opts, err := s.d.Repo.CampaignOptions(ctx, o.ID, "", 500)
if err != nil {
return "", errx.InternalError()
}
name := ""
for _, c := range opts {
if c.ID == cid {
name = c.Name
break
}
}
if name == "" {
return "", errx.New(errx.NotFound, "That campaign is not in this workspace or has finished.")
}
personID, err := s.appPerson(ctx, o, call)
if err != nil {
return "", s.userError(ctx, o, err)
}
if personID == 0 {
return "", errx.New(errx.BadRequest, "This deal has no person to add.")
}
c, p, err := s.appContact(ctx, o, personID)
if err != nil {
return "", s.userError(ctx, o, err)
}
if c != nil {
if _, xerr := s.d.Leads.BulkUpdate(ctx, actor, o.ID, &models.BulkEditContactsData{
ContactSelection: models.ContactSelection{Contacts: []string{c.ID.String()}},
AddCampaigns: []string{cid.String()},
}); xerr != nil {
return "", xerr
}
s.notify(ctx, o.ID, c.ID.String(), "contact")
return name, nil
}
if p == nil || p.Email() == "" {
return "", errx.New(errx.BadRequest, "This person has no email address.")
}
if strings.EqualFold(p.MarketingStatus, "unsubscribed") {
return "", errx.NewWithIdentifier(errx.Conflict, "crm_opted_out", "This person unsubscribed in Pipedrive.")
}
company := ""
if p.OrgID != nil {
if org, err := o.Client.GetOrganization(ctx, *p.OrgID); err == nil {
company = org.Name
}
}
first, last := p.FirstName, p.LastName
if first == "" && last == "" {
first, last, _ = strings.Cut(strings.TrimSpace(p.Name), " ")
}
created, xerr := s.d.Leads.Add(ctx, actor, o.ID, []models.AddContact{{
Email: p.Email(), FirstName: first, LastName: last, Company: company, Phone: p.Phone(), Campaigns: []string{cid.String()},
}})
if xerr != nil {
return "", xerr
}
if len(created) == 1 {
if _, _, err := s.storeContactRecord(ctx, o, created[0].ID, p); err != nil {
return "", errx.InternalError()
}
s.notify(ctx, o.ID, created[0].ID.String(), "contact")
}
return name, nil
}
// AppHeld reports whether any of the person's campaigns is held, for the
// pause modal's preselected choice.
func (s *Service) AppHeld(ctx context.Context, call AppCall) (bool, *errx.Error) {
o, xerr := s.companyOrg(ctx, call.CompanyID)
if xerr != nil {
return false, xerr
}
if _, xerr := s.appMember(ctx, o, call.UserID, models.PermManageCampaigns); xerr != nil {
return false, xerr
}
personID, err := s.appPerson(ctx, o, call)
if err != nil || personID == 0 || s.d.Leads == nil {
return false, nil
}
c, _, err := s.appContact(ctx, o, personID)
if err != nil || c == nil {
return false, nil
}
states, _ := s.d.Leads.CampaignStates(ctx, o.ID, c.ID)
for _, st := range states {
if st.Hold != nil {
return true, nil
}
}
return false, nil
}
// AppSetPaused holds or resumes every campaign the person is in.
func (s *Service) AppSetPaused(ctx context.Context, call AppCall, paused bool) *errx.Error {
o, xerr := s.companyOrg(ctx, call.CompanyID)
if xerr != nil {
return xerr
}
if _, xerr := s.appMember(ctx, o, call.UserID, models.PermManageCampaigns); xerr != nil {
return xerr
}
personID, err := s.appPerson(ctx, o, call)
if err != nil {
return s.userError(ctx, o, err)
}
c, _, err := s.appContact(ctx, o, personID)
if err != nil {
return s.userError(ctx, o, err)
}
if c == nil {
return errx.New(errx.NotFound, "This person is not in Warmbly.")
}
if paused {
if s.d.Holds != nil {
if _, err := s.d.Holds.HoldLeadEverywhere(ctx, c.ID, nil, "Paused from Pipedrive", models.LeadHoldSourceManual); err != nil {
return errx.InternalError()
}
}
} else if _, err := s.d.Repo.ResumeHeldEverywhere(ctx, o.ID, c.ID); err != nil {
return errx.InternalError()
}
s.notify(ctx, o.ID, c.ID.String(), "contact")
return nil
}