mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 08:02:06 +00:00
783 lines
22 KiB
Go
783 lines
22 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/crmmode"
|
|
"github.com/warmbly/warmbly/internal/app/hubspot"
|
|
"github.com/warmbly/warmbly/internal/app/pipedrive"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// CRM mode: which CRM the workspace runs on (Warmbly's own, HubSpot or
|
|
// Pipedrive), and the connected side of it.
|
|
|
|
func (h *Handler) crmReady(c *gin.Context) (uuid.UUID, bool) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return uuid.Nil, false
|
|
}
|
|
if h.CRMModes == nil {
|
|
errx.Handle(c, errx.New(errx.NotImplemented, "CRM providers are not available on this instance"))
|
|
return uuid.Nil, false
|
|
}
|
|
return *orgID, true
|
|
}
|
|
|
|
// crmProvider resolves the CRM the workspace runs on.
|
|
func (h *Handler) crmProvider(c *gin.Context) (crmmode.Provider, uuid.UUID, bool) {
|
|
orgID, ok := h.crmReady(c)
|
|
if !ok {
|
|
return nil, uuid.Nil, false
|
|
}
|
|
p, xerr := h.CRMModes.For(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return nil, uuid.Nil, false
|
|
}
|
|
return p, orgID, true
|
|
}
|
|
|
|
// GetCRMSettings returns the workspace's CRM mode and the connected account.
|
|
func (h *Handler) GetCRMSettings(c *gin.Context) {
|
|
orgID, ok := h.crmReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := h.CRMModes.Settings(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// UpdateCRMSettings switches the CRM and stores the setup choices.
|
|
func (h *Handler) UpdateCRMSettings(c *gin.Context) {
|
|
orgID, ok := h.crmReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body models.UpdateCRMSettings
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, xerr := h.CRMModes.UpdateSettings(c.Request.Context(), orgID, &body)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityIntegration, out.ConnectionID, nil,
|
|
map[string]string{"crm_provider": string(out.Provider)})
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// GetCRMMetadata returns the provider's lifecycle stages, lead statuses, task
|
|
// types, contact properties and pipelines for the pickers.
|
|
func (h *Handler) GetCRMMetadata(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.Metadata(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ListCRMOwners lists the provider's owners and the member each one is.
|
|
func (h *Handler) ListCRMOwners(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.Owners(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
// MapCRMOwner pins an owner to a member (or clears the match).
|
|
func (h *Handler) MapCRMOwner(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ext := strings.TrimSpace(c.Param("externalId"))
|
|
if ext == "" || len(ext) > 64 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "invalid owner id"))
|
|
return
|
|
}
|
|
var body models.MapCRMOwner
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
if xerr := p.MapOwner(c.Request.Context(), orgID, ext, body.UserID); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionAssign, models.AuditEntityIntegration, nil, nil, map[string]string{"crm_owner": ext})
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// GetCRMSyncHealth reports what is waiting, what failed and when each pull ran.
|
|
func (h *Handler) GetCRMSyncHealth(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.SyncHealth(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
type crmJobSelection struct {
|
|
IDs []uuid.UUID `json:"ids" binding:"max=500"`
|
|
}
|
|
|
|
// RetryCRMSyncFailures requeues failed sync jobs (all, or the ids given).
|
|
// Naturally idempotent: requeueing a requeued job changes nothing.
|
|
func (h *Handler) RetryCRMSyncFailures(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body crmJobSelection
|
|
if err := c.ShouldBindJSON(&body); err != nil && err != io.EOF {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
n, xerr := p.RetryFailed(c.Request.Context(), orgID, body.IDs)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"affected": n})
|
|
}
|
|
|
|
// DiscardCRMSyncFailures drops failed sync jobs (all, or the ids given).
|
|
func (h *Handler) DiscardCRMSyncFailures(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body crmJobSelection
|
|
if err := c.ShouldBindJSON(&body); err != nil && err != io.EOF {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
n, xerr := p.DiscardFailed(c.Request.Context(), orgID, body.IDs)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"affected": n})
|
|
}
|
|
|
|
// SyncCRMNow starts a full pull from the provider.
|
|
func (h *Handler) SyncCRMNow(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if xerr := p.SyncNow(c.Request.Context(), orgID); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.Status(http.StatusAccepted)
|
|
}
|
|
|
|
func (h *Handler) crmContactID(c *gin.Context) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// GetCRMContact returns the provider side of a contact: owner, lifecycle stage,
|
|
// lead status, company and the chosen properties.
|
|
func (h *Handler) GetCRMContact(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
contactID, ok := h.crmContactID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.ContactView(c.Request.Context(), orgID, contactID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// RefreshCRMContact pulls a contact's provider record, deals, tasks and notes
|
|
// now. Debounced per contact, so it is safe to call whenever a panel opens.
|
|
func (h *Handler) RefreshCRMContact(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
contactID, ok := h.crmContactID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.RefreshContact(c.Request.Context(), orgID, contactID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// LinkCRMContact finds or creates the contact in the provider.
|
|
func (h *Handler) LinkCRMContact(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
contactID, ok := h.crmContactID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.LinkContact(c.Request.Context(), orgID, contactID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionConnect, models.AuditEntityContact, &contactID, nil, map[string]string{"crm": string(p.Name())})
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// UpdateCRMContact edits owner, lifecycle stage or lead status in place.
|
|
func (h *Handler) UpdateCRMContact(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
contactID, ok := h.crmContactID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body models.UpdateCRMContact
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
for _, v := range []*string{body.OwnerExternalID, body.LifecycleStage, body.LeadStatus} {
|
|
if v != nil && len(*v) > 100 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "value too long"))
|
|
return
|
|
}
|
|
}
|
|
out, xerr := p.UpdateContactRecord(c.Request.Context(), orgID, contactID, &body)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityContact, &contactID, nil, map[string]string{"crm": string(p.Name())})
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ListCRMLists lists the provider's contact lists for import.
|
|
func (h *Handler) ListCRMLists(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := 25
|
|
if v := c.Query("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 || n > 100 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
q := c.Query("q")
|
|
if len(q) > 200 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "query too long"))
|
|
return
|
|
}
|
|
out, xerr := p.Lists(c.Request.Context(), orgID, q, c.Query("cursor"), limit)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// PreviewCRMImport counts who a list import brings in and who it skips.
|
|
func (h *Handler) PreviewCRMImport(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body models.CRMImportRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, xerr := p.PreviewImport(c.Request.Context(), orgID, &body)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ImportCRMList turns a provider list into a contact import draft, finished in
|
|
// the regular import review.
|
|
func (h *Handler) ImportCRMList(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.Unauthorized, "a signed-in member starts an import"))
|
|
return
|
|
}
|
|
var body models.CRMImportRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, xerr := p.Import(c.Request.Context(), orgID, userID, &body)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionImport, models.AuditEntityContact, nil, nil, map[string]string{"crm_list": body.ListID})
|
|
c.JSON(http.StatusCreated, out)
|
|
}
|
|
|
|
// GetCRMBackfill counts the Warmbly-only records a switch would copy.
|
|
func (h *Handler) GetCRMBackfill(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := p.BackfillPreview(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// StartCRMBackfill copies Warmbly's own deals, tasks and notes into the
|
|
// provider once. Idempotent: a second call while one runs is folded into it,
|
|
// and copied records are never copied twice.
|
|
func (h *Handler) StartCRMBackfill(c *gin.Context) {
|
|
p, orgID, ok := h.crmProvider(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body models.CRMBackfillRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.Handle(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
if xerr := p.StartBackfill(c.Request.Context(), orgID, &body); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionImport, models.AuditEntityIntegration, nil, nil, map[string]string{"crm_backfill": string(p.Name())})
|
|
c.Status(http.StatusAccepted)
|
|
}
|
|
|
|
// verifyHubSpot checks a request HubSpot signed with the app's client secret
|
|
// (v3, timestamped) against the public backend URL it was sent to.
|
|
func (h *Handler) verifyHubSpot(c *gin.Context, body []byte) bool {
|
|
u := config.BackendPublicURL() + c.Request.URL.RequestURI()
|
|
return h.HubSpot.VerifySignature(c.Request.Method, u, body,
|
|
c.GetHeader("X-HubSpot-Signature-v3"), c.GetHeader("X-HubSpot-Request-Timestamp"), time.Now()) == nil
|
|
}
|
|
|
|
// hubspotFetchParams are what HubSpot appends to a card's hubspot.fetch call.
|
|
var hubspotFetchParams = []string{"portalid", "appid", "userid", "useremail"}
|
|
|
|
// fromHubSpotFetch reports a request a card sent through hubspot.fetch.
|
|
// HubSpot's webhook and workflow action calls carry none of these.
|
|
func fromHubSpotFetch(q url.Values) bool {
|
|
for k := range q {
|
|
if slices.Contains(hubspotFetchParams, strings.ToLower(k)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// readHubSpotServer reads a signed webhook or workflow action call, whose
|
|
// body names the portal, and refuses one a card sent.
|
|
func (h *Handler) readHubSpotServer(c *gin.Context) ([]byte, bool) {
|
|
if h.HubSpot != nil && fromHubSpotFetch(c.Request.URL.Query()) {
|
|
c.Status(http.StatusForbidden)
|
|
return nil, false
|
|
}
|
|
return h.readHubSpot(c)
|
|
}
|
|
|
|
// readHubSpot reads and verifies a signed HubSpot request body.
|
|
func (h *Handler) readHubSpot(c *gin.Context) ([]byte, bool) {
|
|
if h.HubSpot == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return nil, false
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
|
if err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return nil, false
|
|
}
|
|
if !h.verifyHubSpot(c, body) {
|
|
c.Status(http.StatusUnauthorized)
|
|
return nil, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
// HubSpotWebhook receives HubSpot's app webhooks. It only queues re-reads, so
|
|
// a delivery can at most make a pull come sooner.
|
|
func (h *Handler) HubSpotWebhook(c *gin.Context) {
|
|
body, ok := h.readHubSpotServer(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.HubSpot.HandleWebhook(c.Request.Context(), body); err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// hubspotCardRequest is what the Warmbly card in HubSpot sends. HubSpot adds
|
|
// portalId, userId and userEmail to the query string and signs the request.
|
|
type hubspotCardRequest struct {
|
|
ContactID string `json:"contact_id"`
|
|
Email string `json:"email"`
|
|
CampaignID string `json:"campaign_id"`
|
|
Paused bool `json:"paused"`
|
|
}
|
|
|
|
func (h *Handler) hubspotCardInput(c *gin.Context) (*hubspotCardRequest, *hubspot.User, bool) {
|
|
body, ok := h.readHubSpot(c)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
user, ok := hubspotFetchUser(c.Request.URL.Query())
|
|
if !ok {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "invalid request"))
|
|
return nil, nil, false
|
|
}
|
|
var req hubspotCardRequest
|
|
if err := json.Unmarshal(body, &req); err != nil || len(req.ContactID) > 32 || len(req.Email) > 320 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "invalid request"))
|
|
return nil, nil, false
|
|
}
|
|
return &req, user, true
|
|
}
|
|
|
|
// hubspotFetchUser reads the portal and user HubSpot appended and signed. A
|
|
// second value would let the URL name different ones, so exactly one of each
|
|
// is accepted.
|
|
func hubspotFetchUser(q url.Values) (*hubspot.User, bool) {
|
|
for _, k := range []string{"portalId", "userId", "userEmail"} {
|
|
if len(q[k]) != 1 || strings.TrimSpace(q.Get(k)) == "" {
|
|
return nil, false
|
|
}
|
|
}
|
|
return &hubspot.User{ID: q.Get("userId"), Email: q.Get("userEmail")}, true
|
|
}
|
|
|
|
// HubSpotCard returns the Warmbly card for a HubSpot contact record.
|
|
func (h *Handler) HubSpotCard(c *gin.Context) {
|
|
req, user, ok := h.hubspotCardInput(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := h.HubSpot.Card(c.Request.Context(), c.Query("portalId"), *user, req.ContactID, req.Email)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// HubSpotCardEnroll adds the record's contact to a campaign from the card.
|
|
// Idempotent: enrolling a lead twice leaves one lead.
|
|
func (h *Handler) HubSpotCardEnroll(c *gin.Context) {
|
|
req, user, ok := h.hubspotCardInput(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if xerr := h.HubSpot.Enroll(c.Request.Context(), c.Query("portalId"), req.ContactID, req.Email, req.CampaignID, user); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// HubSpotCardPause holds or resumes the contact's campaigns from the card.
|
|
// Idempotent: it sets a state rather than toggling one.
|
|
func (h *Handler) HubSpotCardPause(c *gin.Context) {
|
|
req, user, ok := h.hubspotCardInput(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if xerr := h.HubSpot.SetPaused(c.Request.Context(), c.Query("portalId"), *user, req.ContactID, req.Email, req.Paused); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// hubspotActionRequest is a custom workflow action execution.
|
|
type hubspotActionRequest struct {
|
|
CallbackID string `json:"callbackId"`
|
|
Origin struct {
|
|
PortalID int64 `json:"portalId"`
|
|
} `json:"origin"`
|
|
Object struct {
|
|
ObjectID int64 `json:"objectId"`
|
|
Properties map[string]string `json:"properties"`
|
|
} `json:"object"`
|
|
InputFields map[string]any `json:"inputFields"`
|
|
FetchOptions struct {
|
|
Q string `json:"q"`
|
|
} `json:"fetchOptions"`
|
|
}
|
|
|
|
// HubSpotActionEnroll runs the "Add to Warmbly campaign" workflow action, as
|
|
// the workspace owner. A refusal answers FAIL_CONTINUE with the reason.
|
|
func (h *Handler) HubSpotActionEnroll(c *gin.Context) {
|
|
body, ok := h.readHubSpotServer(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req hubspotActionRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
campaign, _ := req.InputFields["campaign"].(string)
|
|
xerr := h.HubSpot.Enroll(c.Request.Context(), strconv.FormatInt(req.Origin.PortalID, 10),
|
|
strconv.FormatInt(req.Object.ObjectID, 10), req.Object.Properties["email"], campaign, nil)
|
|
out := gin.H{"hs_execution_state": "SUCCESS", "warmbly_result": "Added to campaign"}
|
|
if xerr != nil {
|
|
out = gin.H{"hs_execution_state": "FAIL_CONTINUE", "warmbly_result": xerr.Message}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"outputFields": out})
|
|
}
|
|
|
|
// HubSpotActionCampaigns lists campaigns for the workflow action's dropdown.
|
|
func (h *Handler) HubSpotActionCampaigns(c *gin.Context) {
|
|
body, ok := h.readHubSpotServer(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req hubspotActionRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
opts, xerr := h.HubSpot.CampaignChoices(c.Request.Context(), strconv.FormatInt(req.Origin.PortalID, 10), req.FetchOptions.Q)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"options": opts, "searchable": true})
|
|
}
|
|
|
|
// PipedriveWebhook receives a Pipedrive webhook for one connection. It only
|
|
// queues a re-read, so a delivery can at most make a pull come sooner. A
|
|
// connection no workspace runs its CRM on answers 410, so Pipedrive retires
|
|
// the webhook.
|
|
func (h *Handler) PipedriveWebhook(c *gin.Context) {
|
|
if h.Pipedrive == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
connID, err := uuid.Parse(c.Param("connectionId"))
|
|
if err != nil {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
user, pass, _ := c.Request.BasicAuth()
|
|
if !h.Pipedrive.VerifyWebhook(connID, user, pass) {
|
|
c.Status(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
|
if err != nil {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
live, err := h.Pipedrive.HandleWebhook(c.Request.Context(), connID, body)
|
|
switch {
|
|
case !live:
|
|
c.Status(http.StatusGone)
|
|
case err != nil:
|
|
c.Status(http.StatusBadRequest)
|
|
default:
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// pipedriveAppCall reads and verifies a panel or modal call: the JWT Pipedrive
|
|
// signs with the app's client secret, naming the user and company in the query.
|
|
func (h *Handler) pipedriveAppCall(c *gin.Context) (pipedrive.AppCall, bool) {
|
|
if h.Pipedrive == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return pipedrive.AppCall{}, false
|
|
}
|
|
q := c.Request.URL.Query()
|
|
for _, k := range []string{"userId", "companyId", "selectedIds", "token"} {
|
|
if len(q[k]) > 1 {
|
|
c.Status(http.StatusBadRequest)
|
|
return pipedrive.AppCall{}, false
|
|
}
|
|
}
|
|
call := pipedrive.AppCall{
|
|
CompanyID: strings.TrimSpace(q.Get("companyId")),
|
|
UserID: strings.TrimSpace(q.Get("userId")),
|
|
Resource: strings.TrimSpace(q.Get("resource")),
|
|
RecordID: strings.TrimSpace(q.Get("selectedIds")),
|
|
}
|
|
if !h.Pipedrive.VerifyAppToken(q.Get("token"), call) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "Pipedrive did not sign this request."}})
|
|
return pipedrive.AppCall{}, false
|
|
}
|
|
return call, true
|
|
}
|
|
|
|
// PipedrivePanel answers the Warmbly JSON panel on Pipedrive person and deal
|
|
// pages. A refusal is shown in the panel with its reason.
|
|
func (h *Handler) PipedrivePanel(c *gin.Context) {
|
|
call, ok := h.pipedriveAppCall(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, xerr := h.Pipedrive.AppPanel(c.Request.Context(), call)
|
|
if xerr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"title": "Warmbly", "subtitle": xerr.Message}})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
type pipedriveModalBody struct {
|
|
Campaign string `json:"campaign"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
func (h *Handler) pipedriveModalBody(c *gin.Context) (*pipedriveModalBody, bool) {
|
|
var body pipedriveModalBody
|
|
if err := json.NewDecoder(io.LimitReader(c.Request.Body, 64<<10)).Decode(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "Warmbly could not read the form."}})
|
|
return nil, false
|
|
}
|
|
return &body, true
|
|
}
|
|
|
|
func pipedriveModalError(c *gin.Context, xerr *errx.Error) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": xerr.Message}})
|
|
}
|
|
|
|
// PipedriveEnroll is the "Add to Warmbly campaign" modal: opening it lists the
|
|
// campaigns, submitting it adds the person. Idempotent: enrolling a lead twice
|
|
// leaves one lead.
|
|
func (h *Handler) PipedriveEnroll(c *gin.Context) {
|
|
call, ok := h.pipedriveAppCall(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if c.Request.Method == http.MethodGet {
|
|
items, xerr := h.Pipedrive.EnrollChoices(ctx, call)
|
|
if xerr != nil {
|
|
pipedriveModalError(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"blocks": gin.H{"campaign": gin.H{"items": items}}, "actions": gin.H{}}})
|
|
return
|
|
}
|
|
body, ok := h.pipedriveModalBody(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
name, xerr := h.Pipedrive.AppEnroll(ctx, call, body.Campaign)
|
|
if xerr != nil {
|
|
pipedriveModalError(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": gin.H{"message": "Added to " + name, "type": "snackbar"}})
|
|
}
|
|
|
|
// PipedrivePause is the "Pause or resume in Warmbly" modal. Idempotent: it sets
|
|
// a state rather than toggling one.
|
|
func (h *Handler) PipedrivePause(c *gin.Context) {
|
|
call, ok := h.pipedriveAppCall(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if c.Request.Method == http.MethodGet {
|
|
held, xerr := h.Pipedrive.AppHeld(ctx, call)
|
|
if xerr != nil {
|
|
pipedriveModalError(c, xerr)
|
|
return
|
|
}
|
|
value := "pause"
|
|
if held {
|
|
value = "resume"
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"blocks": gin.H{"action": gin.H{"value": value}}, "actions": gin.H{}}})
|
|
return
|
|
}
|
|
body, ok := h.pipedriveModalBody(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if body.Action != "pause" && body.Action != "resume" {
|
|
pipedriveModalError(c, errx.New(errx.BadRequest, "Choose pause or resume."))
|
|
return
|
|
}
|
|
if xerr := h.Pipedrive.AppSetPaused(ctx, call, body.Action == "pause"); xerr != nil {
|
|
pipedriveModalError(c, xerr)
|
|
return
|
|
}
|
|
msg := "Campaigns paused"
|
|
if body.Action == "resume" {
|
|
msg = "Campaigns resumed"
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": gin.H{"message": msg, "type": "snackbar"}})
|
|
}
|