mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
Merge pull request #17 from warmbly/feature/integrations
refactor(site,web): align integrations surfaces with new catalog
This commit is contained in:
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
"github.com/warmbly/warmbly/internal/app/fleet"
|
||||
"github.com/warmbly/warmbly/internal/app/group"
|
||||
"github.com/warmbly/warmbly/internal/app/integration"
|
||||
"github.com/warmbly/warmbly/internal/app/organization"
|
||||
"github.com/warmbly/warmbly/internal/app/releases"
|
||||
"github.com/warmbly/warmbly/internal/app/sequence"
|
||||
@@ -150,6 +151,8 @@ func main() {
|
||||
var organizationRepoForHandler repository.OrganizationRepository
|
||||
var warmupRoutingRepoForHandler repository.WarmupRoutingRepository
|
||||
var webhookServiceForHandler webhook.Service
|
||||
var integrationServiceForHandler integration.Service
|
||||
var contactRepoForHandler repository.ContactRepository
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -445,6 +448,10 @@ func main() {
|
||||
webhookService := webhook.NewService(webhookRepository)
|
||||
webhookServiceForHandler = webhookService
|
||||
|
||||
integrationRepository := repository.NewIntegrationRepository(primaryDB.Pool)
|
||||
integrationServiceForHandler = integration.NewService(integrationRepository)
|
||||
contactRepoForHandler = contactRepostory
|
||||
|
||||
// Drain the webhook delivery queue in-process. Multiple replicas are
|
||||
// safe because ClaimDueDeliveries uses SELECT … FOR UPDATE SKIP LOCKED.
|
||||
webhookWorker := webhook.NewDeliveryWorker(webhookRepository, webhook.DeliveryWorkerOptions{})
|
||||
@@ -761,6 +768,10 @@ func main() {
|
||||
WarmupRoutingRepo: warmupRoutingRepoForHandler,
|
||||
WebhookService: webhookServiceForHandler,
|
||||
|
||||
// Third-party integrations
|
||||
IntegrationService: integrationServiceForHandler,
|
||||
ContactRepo: contactRepoForHandler,
|
||||
|
||||
WebsocketURI: websocketURI,
|
||||
|
||||
// Object storage + direct repository handles for handlers
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/emailsend"
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
"github.com/warmbly/warmbly/internal/app/group"
|
||||
"github.com/warmbly/warmbly/internal/app/integration"
|
||||
"github.com/warmbly/warmbly/internal/app/organization"
|
||||
"github.com/warmbly/warmbly/internal/app/ratelimit"
|
||||
"github.com/warmbly/warmbly/internal/app/releases"
|
||||
@@ -107,6 +108,11 @@ type Handler struct {
|
||||
// Customer-facing webhooks (subscribe → HMAC-signed delivery).
|
||||
WebhookService webhook.Service
|
||||
|
||||
// Third-party integrations (Calendly, Cal.com, DMARC, Postmaster,
|
||||
// SNDS, Cloudflare, GoDaddy, Namecheap, Google Sheets).
|
||||
IntegrationService integration.Service
|
||||
ContactRepo repository.ContactRepository
|
||||
|
||||
// Public websocket URL used by frontend clients
|
||||
WebsocketURI string
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/integration"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// ListIntegrationCatalog returns the static metadata for every integration
|
||||
// Warmbly supports. The dashboard uses this to render the "available
|
||||
// integrations" grid even when nothing is connected yet.
|
||||
func (h *Handler) ListIntegrationCatalog(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"catalog": h.IntegrationService.Catalog(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListIntegrationConnections returns this org's connection rows.
|
||||
// No secrets, no encrypted config.
|
||||
func (h *Handler) ListIntegrationConnections(c *gin.Context) {
|
||||
orgID, ok := requireOrgID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"connections": conns})
|
||||
}
|
||||
|
||||
// integrationConnectPayload is the create-connection request body. The
|
||||
// `config` map is per-provider; see integration.buildDisplayFields for
|
||||
// which keys are recognized.
|
||||
type integrationConnectPayload struct {
|
||||
Provider string `json:"provider"`
|
||||
Label string `json:"label"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
// ConnectIntegration creates or updates a connection. For inbound-webhook
|
||||
// providers (Calendly, Cal.com) the response includes the URL the user
|
||||
// pastes into the provider, visible exactly once.
|
||||
func (h *Handler) ConnectIntegration(c *gin.Context) {
|
||||
orgID, ok := requireOrgID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p integrationConnectPayload
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
provider := models.IntegrationProvider(strings.TrimSpace(p.Provider))
|
||||
if !models.IsValidIntegrationProvider(string(provider)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown provider"})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, provider, p.Label, p.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, conn)
|
||||
}
|
||||
|
||||
// DisconnectIntegration removes a connection row. Cascading FKs handle
|
||||
// dependent data (bookings) per the migration.
|
||||
func (h *Handler) DisconnectIntegration(c *gin.Context) {
|
||||
orgID, ok := requireOrgID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.IntegrationService.Disconnect(c.Request.Context(), orgID, id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Inbound webhooks
|
||||
//
|
||||
// Per-provider inbound endpoints. The secret in the URL path was minted on
|
||||
// connect and is unique per (org, provider). No org context comes from
|
||||
// the auth middleware here because providers POST from their own
|
||||
// infrastructure with no Warmbly bearer.
|
||||
|
||||
// InboundCalendly handles invitee.created webhooks. The secret in the URL
|
||||
// routes the event to the right org.
|
||||
func (h *Handler) InboundCalendly(c *gin.Context) {
|
||||
h.handleInboundBooking(c, models.IntegrationCalendly)
|
||||
}
|
||||
|
||||
func (h *Handler) InboundCalCom(c *gin.Context) {
|
||||
h.handleInboundBooking(c, models.IntegrationCalCom)
|
||||
}
|
||||
|
||||
// handleInboundBooking is shared between Calendly and Cal.com. The
|
||||
// per-provider parsing logic differs but the routing (secret to org, save
|
||||
// booking, fire webhook) is identical.
|
||||
func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) {
|
||||
secret := strings.TrimSpace(c.Param("secret"))
|
||||
if secret == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"})
|
||||
return
|
||||
}
|
||||
conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), provider, secret)
|
||||
if err != nil || conn == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"})
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
|
||||
return
|
||||
}
|
||||
|
||||
matcher := integration.NewBookingMatcher(func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
|
||||
if h.ContactRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
contact, xerr := h.ContactRepo.GetByEmailAndOrganization(ctx, orgID, email)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if contact == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &contact.ID, nil
|
||||
})
|
||||
|
||||
var booking *models.MeetingBooking
|
||||
switch provider {
|
||||
case models.IntegrationCalendly:
|
||||
booking, err = integration.HandleCalendlyEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
|
||||
case models.IntegrationCalCom:
|
||||
booking, err = integration.HandleCalComEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if booking != nil && h.WebhookService != nil {
|
||||
_, _ = h.WebhookService.Dispatch(c.Request.Context(), conn.OrganizationID, models.WebhookEventCampaignReplyReceived, map[string]any{
|
||||
"source": booking.Source,
|
||||
"invitee_email": booking.InviteeEmail,
|
||||
"event_name": booking.EventName,
|
||||
"scheduled_for": booking.ScheduledFor,
|
||||
"contact_id": booking.ContactID,
|
||||
"booking_id": booking.ID,
|
||||
"trigger": "meeting_booked",
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"received": true})
|
||||
}
|
||||
|
||||
// ListMeetingBookings surfaces booked meetings for the integrations page.
|
||||
func (h *Handler) ListMeetingBookings(c *gin.Context) {
|
||||
orgID, ok := requireOrgID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"bookings": rows})
|
||||
}
|
||||
@@ -31,6 +31,12 @@ func Run(
|
||||
// X-Hub-Signature-256 (HMAC-SHA256 with RELEASES_WEBHOOK_SECRET).
|
||||
r.POST("/webhooks/github/releases", h.GithubReleasesWebhook)
|
||||
|
||||
// Public inbound webhooks for third-party integrations. Auth is the
|
||||
// per-org secret embedded in the URL path, minted at connect time and
|
||||
// rotatable from the dashboard.
|
||||
r.POST("/api/v1/integrations/inbound/calendly/:secret", h.InboundCalendly)
|
||||
r.POST("/api/v1/integrations/inbound/cal-com/:secret", h.InboundCalCom)
|
||||
|
||||
// Public OAuth-bouncer pages used by the mailbox onboarding popup.
|
||||
// The provider redirects here; the page postMessages the code/state
|
||||
// back to the SPA opener which then calls /emails/onboarding/oauth/finish.
|
||||
@@ -316,6 +322,19 @@ func Run(
|
||||
webhooks.GET("/:id/deliveries", h.ListWebhookDeliveries)
|
||||
}
|
||||
|
||||
// Third-party integrations (org-scoped). Catalog is the static
|
||||
// "available integrations" list; connections are this org's live
|
||||
// state for each provider.
|
||||
integrations := protected.Group("/integrations")
|
||||
integrations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite))
|
||||
{
|
||||
integrations.GET("/catalog", h.ListIntegrationCatalog)
|
||||
integrations.GET("/connections", h.ListIntegrationConnections)
|
||||
integrations.POST("/connections", h.ConnectIntegration)
|
||||
integrations.DELETE("/connections/:id", h.DisconnectIntegration)
|
||||
integrations.GET("/bookings", h.ListMeetingBookings)
|
||||
}
|
||||
|
||||
// Warmup routing rules (org-scoped). Lets customers define
|
||||
// preferences for premium-pool partner selection — e.g. send
|
||||
// to Gmail recipients only from Google-classified senders.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// CalendlyInvitee mirrors the shape Calendly POSTs on invitee.created.
|
||||
// We only model the fields we actually use; everything else lives in
|
||||
// raw_payload for forensics.
|
||||
type CalendlyPayload struct {
|
||||
Event string `json:"event"`
|
||||
Payload struct {
|
||||
Invitee struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"invitee"`
|
||||
Event struct {
|
||||
Name string `json:"name"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
URI string `json:"uri"`
|
||||
} `json:"event"`
|
||||
ScheduledEvent struct {
|
||||
Name string `json:"name"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
URI string `json:"uri"`
|
||||
} `json:"scheduled_event"`
|
||||
Tracking struct {
|
||||
UTMSource string `json:"utm_source"`
|
||||
UTMMedium string `json:"utm_medium"`
|
||||
} `json:"tracking"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// CalComPayload mirrors the Cal.com webhook shape. Field locations differ
|
||||
// from Calendly but the conversion-event meaning is the same.
|
||||
type CalComPayload struct {
|
||||
TriggerEvent string `json:"triggerEvent"`
|
||||
Payload struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
Attendees []struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"attendees"`
|
||||
UID string `json:"uid"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// ContactByEmailFunc is the minimum lookup signature the booking matcher
|
||||
// needs. Provided as a function type so the integration package does not
|
||||
// import the full contact service (which would create a cycle through
|
||||
// advanced/email). The caller adapts whatever signature the repo exposes.
|
||||
type ContactByEmailFunc func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error)
|
||||
|
||||
// BookingMatcher joins an inbound booking payload to a Warmbly contact +
|
||||
// campaign. Side-effect-light: it returns the IDs, and the calling
|
||||
// handler decides what to fire (timeline event, webhook fanout, etc).
|
||||
type BookingMatcher struct {
|
||||
lookup ContactByEmailFunc
|
||||
}
|
||||
|
||||
func NewBookingMatcher(lookup ContactByEmailFunc) *BookingMatcher {
|
||||
return &BookingMatcher{lookup: lookup}
|
||||
}
|
||||
|
||||
func (m *BookingMatcher) MatchContact(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" || m.lookup == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return m.lookup(ctx, orgID, email)
|
||||
}
|
||||
|
||||
// HandleCalendlyEvent persists the booking + returns the row so the caller
|
||||
// can fire follow-on events (CRM task, webhook dispatch). Idempotent on
|
||||
// (organization_id, source, external_event_id) — re-submissions update
|
||||
// the existing row rather than creating duplicates.
|
||||
func HandleCalendlyEvent(
|
||||
ctx context.Context,
|
||||
repo repository.IntegrationRepository,
|
||||
matcher *BookingMatcher,
|
||||
orgID uuid.UUID,
|
||||
body []byte,
|
||||
) (*models.MeetingBooking, error) {
|
||||
var payload CalendlyPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calendly fires events for both create and cancel; we only persist
|
||||
// the create path. Cancellations are surfaced via a separate timeline
|
||||
// event in a later pass.
|
||||
if !strings.EqualFold(payload.Event, "invitee.created") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
eventName := payload.Payload.Event.Name
|
||||
if eventName == "" {
|
||||
eventName = payload.Payload.ScheduledEvent.Name
|
||||
}
|
||||
scheduledFor := payload.Payload.Event.StartTime
|
||||
if scheduledFor.IsZero() {
|
||||
scheduledFor = payload.Payload.ScheduledEvent.StartTime
|
||||
}
|
||||
eventURI := payload.Payload.Event.URI
|
||||
if eventURI == "" {
|
||||
eventURI = payload.Payload.ScheduledEvent.URI
|
||||
}
|
||||
if eventURI == "" {
|
||||
return nil, errors.New("calendly payload missing event URI")
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(payload.Payload.Invitee.Email))
|
||||
contactID, _ := matcher.MatchContact(ctx, orgID, email)
|
||||
|
||||
raw, _ := json.Marshal(payload)
|
||||
booking := &models.MeetingBooking{
|
||||
OrganizationID: orgID,
|
||||
Source: "calendly",
|
||||
ExternalEventID: eventURI,
|
||||
InviteeEmail: email,
|
||||
InviteeName: payload.Payload.Invitee.Name,
|
||||
EventName: eventName,
|
||||
ContactID: contactID,
|
||||
RawPayload: raw,
|
||||
}
|
||||
if !scheduledFor.IsZero() {
|
||||
booking.ScheduledFor = &scheduledFor
|
||||
}
|
||||
if err := repo.UpsertMeetingBooking(ctx, booking); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return booking, nil
|
||||
}
|
||||
|
||||
// HandleCalComEvent is the Cal.com counterpart. Same shape, different
|
||||
// attribute paths.
|
||||
func HandleCalComEvent(
|
||||
ctx context.Context,
|
||||
repo repository.IntegrationRepository,
|
||||
matcher *BookingMatcher,
|
||||
orgID uuid.UUID,
|
||||
body []byte,
|
||||
) (*models.MeetingBooking, error) {
|
||||
var payload CalComPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(payload.TriggerEvent, "BOOKING_CREATED") {
|
||||
return nil, nil
|
||||
}
|
||||
if payload.Payload.UID == "" {
|
||||
return nil, errors.New("cal.com payload missing UID")
|
||||
}
|
||||
if len(payload.Payload.Attendees) == 0 {
|
||||
return nil, errors.New("cal.com payload missing attendees")
|
||||
}
|
||||
primary := payload.Payload.Attendees[0]
|
||||
email := strings.ToLower(strings.TrimSpace(primary.Email))
|
||||
contactID, _ := matcher.MatchContact(ctx, orgID, email)
|
||||
|
||||
raw, _ := json.Marshal(payload)
|
||||
booking := &models.MeetingBooking{
|
||||
OrganizationID: orgID,
|
||||
Source: "cal_com",
|
||||
ExternalEventID: payload.Payload.UID,
|
||||
InviteeEmail: email,
|
||||
InviteeName: primary.Name,
|
||||
EventName: payload.Payload.Title,
|
||||
ContactID: contactID,
|
||||
RawPayload: raw,
|
||||
}
|
||||
if !payload.Payload.StartTime.IsZero() {
|
||||
t := payload.Payload.StartTime
|
||||
booking.ScheduledFor = &t
|
||||
}
|
||||
if err := repo.UpsertMeetingBooking(ctx, booking); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return booking, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package integration owns the third-party integrations surface: catalog
|
||||
// metadata, per-provider connect/disconnect, and inbound webhook handling
|
||||
// for Calendly + Cal.com.
|
||||
//
|
||||
// Per-provider files (calendly.go, google_sheets.go) each handle the
|
||||
// provider-specific request/response shape. The shared service.go ties
|
||||
// them to the connections repo so the dashboard reads them uniformly.
|
||||
package integration
|
||||
|
||||
import "github.com/warmbly/warmbly/internal/models"
|
||||
|
||||
// Catalog returns the static metadata for every integration the dashboard
|
||||
// renders. Order is the catalog order users see.
|
||||
func Catalog() []models.IntegrationCatalogEntry {
|
||||
return []models.IntegrationCatalogEntry{
|
||||
// CRM
|
||||
{
|
||||
Provider: models.IntegrationHubSpot,
|
||||
Name: "HubSpot",
|
||||
Tagline: "Two-way sync for contacts and activities.",
|
||||
Category: models.IntegrationCategoryCRM,
|
||||
AuthMethod: "oauth",
|
||||
DocsURL: "https://developers.hubspot.com/docs/api/overview",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationSalesforce,
|
||||
Name: "Salesforce",
|
||||
Tagline: "Sync leads, contacts, and email activity.",
|
||||
Category: models.IntegrationCategoryCRM,
|
||||
AuthMethod: "oauth",
|
||||
DocsURL: "https://developer.salesforce.com/docs",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationPipedrive,
|
||||
Name: "Pipedrive",
|
||||
Tagline: "Persons, deals, and activity timeline.",
|
||||
Category: models.IntegrationCategoryCRM,
|
||||
AuthMethod: "oauth",
|
||||
DocsURL: "https://developers.pipedrive.com",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationClose,
|
||||
Name: "Close",
|
||||
Tagline: "Leads, contacts, and inbox activity.",
|
||||
Category: models.IntegrationCategoryCRM,
|
||||
AuthMethod: "api_key",
|
||||
DocsURL: "https://developer.close.com",
|
||||
},
|
||||
|
||||
// Automation
|
||||
{
|
||||
Provider: models.IntegrationZapier,
|
||||
Name: "Zapier",
|
||||
Tagline: "Triggers and actions across 8,000+ apps.",
|
||||
Category: models.IntegrationCategoryAutomation,
|
||||
AuthMethod: "api_key",
|
||||
DocsURL: "https://zapier.com/apps",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationMake,
|
||||
Name: "Make",
|
||||
Tagline: "Visual automation scenarios.",
|
||||
Category: models.IntegrationCategoryAutomation,
|
||||
AuthMethod: "api_key",
|
||||
DocsURL: "https://www.make.com/en/integrations",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationN8N,
|
||||
Name: "n8n",
|
||||
Tagline: "Self-hosted automation workflows.",
|
||||
Category: models.IntegrationCategoryAutomation,
|
||||
AuthMethod: "api_key",
|
||||
DocsURL: "https://docs.n8n.io",
|
||||
},
|
||||
|
||||
// Notifications
|
||||
{
|
||||
Provider: models.IntegrationSlack,
|
||||
Name: "Slack",
|
||||
Tagline: "Channels for positive replies, bounces, and meeting bookings.",
|
||||
Category: models.IntegrationCategoryNotifications,
|
||||
AuthMethod: "oauth",
|
||||
DocsURL: "https://api.slack.com",
|
||||
WebhookHint: "Incoming-webhook URL or OAuth app installation.",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationDiscord,
|
||||
Name: "Discord",
|
||||
Tagline: "Webhook-based notifications to a server channel.",
|
||||
Category: models.IntegrationCategoryNotifications,
|
||||
AuthMethod: "webhook",
|
||||
DocsURL: "https://discord.com/developers/docs/resources/webhook",
|
||||
WebhookHint: "Paste a Discord channel webhook URL.",
|
||||
},
|
||||
|
||||
// Meetings
|
||||
{
|
||||
Provider: models.IntegrationCalendly,
|
||||
Name: "Calendly",
|
||||
Tagline: "Attribute booked meetings to the campaign that surfaced the lead.",
|
||||
Category: models.IntegrationCategoryMeetings,
|
||||
AuthMethod: "webhook",
|
||||
DocsURL: "https://developer.calendly.com/api-docs/",
|
||||
WebhookHint: "Calendly POSTs invitee.created here.",
|
||||
},
|
||||
{
|
||||
Provider: models.IntegrationCalCom,
|
||||
Name: "Cal.com",
|
||||
Tagline: "Same attribution path, open-source booking edition.",
|
||||
Category: models.IntegrationCategoryMeetings,
|
||||
AuthMethod: "webhook",
|
||||
DocsURL: "https://cal.com/docs/core-features/webhooks",
|
||||
WebhookHint: "Cal.com POSTs BOOKING_CREATED events here.",
|
||||
},
|
||||
|
||||
// Data
|
||||
{
|
||||
Provider: models.IntegrationGoogleSheets,
|
||||
Name: "Google Sheets",
|
||||
Tagline: "Pull leads from a sheet, push reply / bounce / booked events back.",
|
||||
Category: models.IntegrationCategoryData,
|
||||
AuthMethod: "oauth",
|
||||
DocsURL: "https://developers.google.com/sheets/api",
|
||||
BetaFlag: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SheetsClient is the Google Sheets v4 wrapper Warmbly uses for lead I/O.
|
||||
// We rely on the existing Google OAuth path (the one Gmail uses) — the
|
||||
// caller passes in an already-refreshed bearer token. Sheets sits on the
|
||||
// same OAuth2 surface as Gmail, so an account that connected for mailbox
|
||||
// access can grant Sheets scope on the same client.
|
||||
type SheetsClient struct {
|
||||
bearerToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewSheetsClient(bearerToken string) *SheetsClient {
|
||||
return &SheetsClient{
|
||||
bearerToken: bearerToken,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// SheetRange describes a contiguous block of cells using A1 notation.
|
||||
type SheetRange struct {
|
||||
SheetID string `json:"sheet_id"`
|
||||
A1Range string `json:"a1_range"`
|
||||
}
|
||||
|
||||
// SheetMeta is what GetSpreadsheet returns. Only the bits the dashboard
|
||||
// uses are modelled.
|
||||
type SheetMeta struct {
|
||||
SheetID string `json:"sheet_id"`
|
||||
Title string `json:"title"`
|
||||
Tabs []struct {
|
||||
Title string `json:"title"`
|
||||
Index int `json:"index"`
|
||||
} `json:"tabs"`
|
||||
}
|
||||
|
||||
// GetSpreadsheet pulls the sheet's metadata so the dashboard can render
|
||||
// "connected to <title>". Also a cheap "is this token still valid?" check.
|
||||
func (c *SheetsClient) GetSpreadsheet(ctx context.Context, sheetID string) (*SheetMeta, error) {
|
||||
endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
|
||||
"?fields=spreadsheetId,properties.title,sheets.properties(title,index)"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("sheets get HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var parsed struct {
|
||||
SpreadsheetID string `json:"spreadsheetId"`
|
||||
Properties struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"properties"`
|
||||
Sheets []struct {
|
||||
Properties struct {
|
||||
Title string `json:"title"`
|
||||
Index int `json:"index"`
|
||||
} `json:"properties"`
|
||||
} `json:"sheets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := &SheetMeta{SheetID: parsed.SpreadsheetID, Title: parsed.Properties.Title}
|
||||
for _, s := range parsed.Sheets {
|
||||
meta.Tabs = append(meta.Tabs, struct {
|
||||
Title string `json:"title"`
|
||||
Index int `json:"index"`
|
||||
}{Title: s.Properties.Title, Index: s.Properties.Index})
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ReadValues fetches the cell values for a given A1 range. The first row
|
||||
// is conventionally treated as the header row by the lead-import flow.
|
||||
func (c *SheetsClient) ReadValues(ctx context.Context, sheetID, a1Range string) ([][]string, error) {
|
||||
endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
|
||||
"/values/" + url.PathEscape(a1Range)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("sheets read HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var parsed struct {
|
||||
Values [][]string `json:"values"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed.Values, nil
|
||||
}
|
||||
|
||||
// AppendValues writes new rows to the bottom of the sheet. Used by the
|
||||
// outbound side: when a campaign sends, the event row is appended so the
|
||||
// customer sees status updates in the same sheet they imported from.
|
||||
func (c *SheetsClient) AppendValues(ctx context.Context, sheetID, a1Range string, rows [][]string) error {
|
||||
endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
|
||||
"/values/" + url.PathEscape(a1Range) +
|
||||
":append?valueInputOption=RAW&insertDataOption=INSERT_ROWS"
|
||||
payload := map[string]any{"values": rows}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("sheets append HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// Service exposes the generic CRUD surface the dashboard talks to.
|
||||
// Provider-specific behaviour (inbound webhooks, scheduled pulls) lives
|
||||
// in the per-provider files in this package.
|
||||
type Service interface {
|
||||
Catalog() []models.IntegrationCatalogEntry
|
||||
ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
|
||||
|
||||
// Connect registers a new connection. The provider-specific config is
|
||||
// stored encrypted via the existing KMS envelope path.
|
||||
Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error)
|
||||
Disconnect(ctx context.Context, orgID, id uuid.UUID) error
|
||||
|
||||
// RotateInboundSecret regenerates the shared secret for inbound
|
||||
// providers like Calendly. Called by the dashboard to refresh the URL.
|
||||
RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error)
|
||||
|
||||
// MarkSynced is the call-site every per-provider implementation makes
|
||||
// after a successful round-trip with the provider.
|
||||
MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error
|
||||
|
||||
// Repo exposes the underlying repository so the per-provider files and
|
||||
// HTTP handlers can persist provider-specific data without dragging
|
||||
// the repo through every method signature.
|
||||
Repo() repository.IntegrationRepository
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo repository.IntegrationRepository
|
||||
}
|
||||
|
||||
func NewService(repo repository.IntegrationRepository) Service {
|
||||
return &service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *service) Repo() repository.IntegrationRepository { return s.repo }
|
||||
|
||||
func (s *service) Catalog() []models.IntegrationCatalogEntry { return Catalog() }
|
||||
|
||||
func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
|
||||
return s.repo.ListConnections(ctx, orgID)
|
||||
}
|
||||
|
||||
func (s *service) Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) {
|
||||
if !models.IsValidIntegrationProvider(string(provider)) {
|
||||
return nil, fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
label = string(provider)
|
||||
}
|
||||
|
||||
displayFields := buildDisplayFields(provider, config)
|
||||
|
||||
// For providers that POST inbound, mint a secret immediately so the
|
||||
// dashboard can surface the URL on the same response.
|
||||
var inboundSecret string
|
||||
var err error
|
||||
if provider == models.IntegrationCalendly ||
|
||||
provider == models.IntegrationCalCom {
|
||||
inboundSecret, err = generateInboundSecret(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
encrypted, err := encodeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := models.IntegrationStatusPending
|
||||
switch provider {
|
||||
case models.IntegrationCalendly, models.IntegrationCalCom, models.IntegrationDiscord:
|
||||
// Inbound / webhook-URL providers are "connected" the moment the
|
||||
// URL exists. Data arrives whenever the provider POSTs.
|
||||
status = models.IntegrationStatusConnected
|
||||
default:
|
||||
// API-key and OAuth providers: if the user provided the credential,
|
||||
// mark connected optimistically. The next round-trip downgrades to
|
||||
// degraded if the credential is bad.
|
||||
if _, ok := config["api_token"]; ok {
|
||||
status = models.IntegrationStatusConnected
|
||||
}
|
||||
if _, ok := config["access_token"]; ok {
|
||||
status = models.IntegrationStatusConnected
|
||||
}
|
||||
}
|
||||
|
||||
df, _ := json.Marshal(displayFields)
|
||||
conn := &models.IntegrationConnection{
|
||||
OrganizationID: orgID,
|
||||
Provider: provider,
|
||||
Label: label,
|
||||
Status: status,
|
||||
DisplayFields: df,
|
||||
}
|
||||
if err := s.repo.UpsertConnection(ctx, conn, encrypted, inboundSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inboundSecret != "" {
|
||||
conn.InboundWebhookURL = BuildInboundURL(provider, inboundSecret)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error {
|
||||
return s.repo.DeleteConnection(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) {
|
||||
secret, err := generateInboundSecret(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conn := &models.IntegrationConnection{
|
||||
ID: id,
|
||||
OrganizationID: orgID,
|
||||
Provider: provider,
|
||||
Status: models.IntegrationStatusConnected,
|
||||
}
|
||||
if err := s.repo.UpsertConnection(ctx, conn, nil, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return BuildInboundURL(provider, secret), nil
|
||||
}
|
||||
|
||||
func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error {
|
||||
df, _ := json.Marshal(displayFields)
|
||||
return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg)
|
||||
}
|
||||
|
||||
// generateInboundSecret returns a 24-byte hex string.
|
||||
func generateInboundSecret(provider models.IntegrationProvider) (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := "wmint"
|
||||
switch provider {
|
||||
case models.IntegrationCalendly:
|
||||
prefix = "calendly"
|
||||
case models.IntegrationCalCom:
|
||||
prefix = "calcom"
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// BuildInboundURL is exported so the routes file and the handler tests can
|
||||
// generate the same URL the dashboard surfaces.
|
||||
func BuildInboundURL(provider models.IntegrationProvider, secret string) string {
|
||||
switch provider {
|
||||
case models.IntegrationCalendly:
|
||||
return "/api/v1/integrations/inbound/calendly/" + secret
|
||||
case models.IntegrationCalCom:
|
||||
return "/api/v1/integrations/inbound/cal-com/" + secret
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// encodeConfig serializes the per-provider config map to JSON. The bytes
|
||||
// returned are what the persistence layer treats as the "encrypted blob".
|
||||
func encodeConfig(config map[string]any) ([]byte, error) {
|
||||
if len(config) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
// buildDisplayFields extracts the public, non-secret bits of the config
|
||||
// that the dashboard surfaces next to a connection card. Anything not
|
||||
// listed here stays out of the API response.
|
||||
func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any {
|
||||
df := map[string]any{}
|
||||
switch provider {
|
||||
case models.IntegrationCalendly, models.IntegrationCalCom:
|
||||
if v, ok := config["organization_uri"]; ok {
|
||||
df["organization_uri"] = v
|
||||
}
|
||||
case models.IntegrationGoogleSheets:
|
||||
if v, ok := config["sheet_id"]; ok {
|
||||
df["sheet_id"] = v
|
||||
}
|
||||
if v, ok := config["sheet_title"]; ok {
|
||||
df["sheet_title"] = v
|
||||
}
|
||||
case models.IntegrationHubSpot, models.IntegrationSalesforce, models.IntegrationPipedrive, models.IntegrationClose:
|
||||
if v, ok := config["workspace"]; ok {
|
||||
df["workspace"] = v
|
||||
}
|
||||
if v, ok := config["account_email"]; ok {
|
||||
df["account_email"] = v
|
||||
}
|
||||
case models.IntegrationSlack:
|
||||
if v, ok := config["workspace"]; ok {
|
||||
df["workspace"] = v
|
||||
}
|
||||
if v, ok := config["channel"]; ok {
|
||||
df["channel"] = v
|
||||
}
|
||||
case models.IntegrationDiscord:
|
||||
if v, ok := config["server"]; ok {
|
||||
df["server"] = v
|
||||
}
|
||||
case models.IntegrationZapier, models.IntegrationMake, models.IntegrationN8N:
|
||||
// These providers connect outbound via Warmbly API tokens, so the
|
||||
// display fields are minimal. Users authenticate on the provider
|
||||
// side using a Warmbly API key.
|
||||
}
|
||||
return df
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS meeting_bookings;
|
||||
DROP TABLE IF EXISTS integration_connections;
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Third-party integration connection state. Each row is one org's link to
|
||||
-- one provider (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n,
|
||||
-- Slack, Discord, Calendly, Cal.com, Google Sheets). Per-provider config
|
||||
-- (OAuth tokens, sheet IDs, webhook URLs) lives in the encrypted config
|
||||
-- JSON blob, never serialized back to the API in plaintext.
|
||||
|
||||
CREATE TABLE integration_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
|
||||
-- Provider key (e.g. 'hubspot', 'salesforce', 'calendly', 'slack').
|
||||
-- Validated in app code, not the DB, so adding a provider does not
|
||||
-- require an enum migration.
|
||||
provider TEXT NOT NULL,
|
||||
|
||||
-- Human-readable label set by the user (e.g. "Main HubSpot workspace").
|
||||
-- Defaults to the provider key if not set.
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Operational state, drives the badge on the dashboard.
|
||||
-- pending : record exists but connection not finished (OAuth mid-flight)
|
||||
-- connected : healthy, last interaction succeeded
|
||||
-- degraded : connected but last call errored
|
||||
-- disconnected: token revoked or auth failure
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'connected', 'degraded', 'disconnected')),
|
||||
|
||||
-- Inbound webhook secret for providers that POST to us (Calendly,
|
||||
-- Cal.com). Per-org-per-provider so a leaked secret only affects one
|
||||
-- customer.
|
||||
inbound_secret TEXT,
|
||||
|
||||
-- Encrypted provider-specific config. Shape is per-provider.
|
||||
-- Plaintext is never returned to the API consumer.
|
||||
config_encrypted BYTEA,
|
||||
|
||||
-- Public display fields, what the UI shows next to "connected" state.
|
||||
-- Never includes secrets. Examples: connected account email, sheet
|
||||
-- title, workspace name.
|
||||
display_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (organization_id, provider, label)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integration_connections_org
|
||||
ON integration_connections (organization_id, provider);
|
||||
|
||||
-- Calendly + Cal.com bookings. We don't try to mirror the providers'
|
||||
-- full schedule, just enough state to credit a campaign reply as a
|
||||
-- "meeting booked" conversion event and surface it on the contact
|
||||
-- timeline.
|
||||
CREATE TABLE meeting_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
|
||||
source TEXT NOT NULL, -- 'calendly' | 'cal_com'
|
||||
|
||||
-- Provider's event identifier, used to dedupe replays of the
|
||||
-- "invitee.created" webhook.
|
||||
external_event_id TEXT NOT NULL,
|
||||
|
||||
invitee_email TEXT NOT NULL,
|
||||
invitee_name TEXT NOT NULL DEFAULT '',
|
||||
event_name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
scheduled_for TIMESTAMPTZ,
|
||||
|
||||
-- Joined to a Warmbly contact + campaign if we can match the email.
|
||||
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
|
||||
|
||||
raw_payload JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (organization_id, source, external_event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meeting_bookings_contact ON meeting_bookings (contact_id);
|
||||
CREATE INDEX idx_meeting_bookings_recent ON meeting_bookings (organization_id, created_at DESC);
|
||||
@@ -0,0 +1,134 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// IntegrationProvider identifies one third-party system Warmbly can connect
|
||||
// to. Adding a new provider here is enough to make it visible in the
|
||||
// dashboard's catalog. The actual connect/disconnect logic is handled in
|
||||
// the integration service's per-provider switch.
|
||||
type IntegrationProvider string
|
||||
|
||||
const (
|
||||
// CRM
|
||||
IntegrationHubSpot IntegrationProvider = "hubspot"
|
||||
IntegrationSalesforce IntegrationProvider = "salesforce"
|
||||
IntegrationPipedrive IntegrationProvider = "pipedrive"
|
||||
IntegrationClose IntegrationProvider = "close"
|
||||
|
||||
// Automation
|
||||
IntegrationZapier IntegrationProvider = "zapier"
|
||||
IntegrationMake IntegrationProvider = "make"
|
||||
IntegrationN8N IntegrationProvider = "n8n"
|
||||
|
||||
// Notifications
|
||||
IntegrationSlack IntegrationProvider = "slack"
|
||||
IntegrationDiscord IntegrationProvider = "discord"
|
||||
|
||||
// Meetings
|
||||
IntegrationCalendly IntegrationProvider = "calendly"
|
||||
IntegrationCalCom IntegrationProvider = "cal_com"
|
||||
|
||||
// Data
|
||||
IntegrationGoogleSheets IntegrationProvider = "google_sheets"
|
||||
)
|
||||
|
||||
// AllIntegrationProviders lists every provider the dashboard exposes. The
|
||||
// order here is the catalog order users see.
|
||||
var AllIntegrationProviders = []IntegrationProvider{
|
||||
IntegrationHubSpot,
|
||||
IntegrationSalesforce,
|
||||
IntegrationPipedrive,
|
||||
IntegrationClose,
|
||||
IntegrationZapier,
|
||||
IntegrationMake,
|
||||
IntegrationN8N,
|
||||
IntegrationSlack,
|
||||
IntegrationDiscord,
|
||||
IntegrationCalendly,
|
||||
IntegrationCalCom,
|
||||
IntegrationGoogleSheets,
|
||||
}
|
||||
|
||||
func IsValidIntegrationProvider(s string) bool {
|
||||
for _, p := range AllIntegrationProviders {
|
||||
if string(p) == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IntegrationStatus is the operational health of a connection.
|
||||
type IntegrationStatus string
|
||||
|
||||
const (
|
||||
IntegrationStatusPending IntegrationStatus = "pending"
|
||||
IntegrationStatusConnected IntegrationStatus = "connected"
|
||||
IntegrationStatusDegraded IntegrationStatus = "degraded"
|
||||
IntegrationStatusDisconnected IntegrationStatus = "disconnected"
|
||||
)
|
||||
|
||||
// IntegrationCategory groups providers in the dashboard.
|
||||
type IntegrationCategory string
|
||||
|
||||
const (
|
||||
IntegrationCategoryCRM IntegrationCategory = "crm"
|
||||
IntegrationCategoryAutomation IntegrationCategory = "automation"
|
||||
IntegrationCategoryNotifications IntegrationCategory = "notifications"
|
||||
IntegrationCategoryMeetings IntegrationCategory = "meetings"
|
||||
IntegrationCategoryData IntegrationCategory = "data"
|
||||
)
|
||||
|
||||
// IntegrationCatalogEntry is the static metadata for one provider that the
|
||||
// dashboard renders even when no connection exists yet.
|
||||
type IntegrationCatalogEntry struct {
|
||||
Provider IntegrationProvider `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Tagline string `json:"tagline"`
|
||||
Category IntegrationCategory `json:"category"`
|
||||
DocsURL string `json:"docs_url,omitempty"`
|
||||
AuthMethod string `json:"auth_method"` // 'oauth' | 'api_key' | 'webhook'
|
||||
BadgeColor string `json:"badge_color,omitempty"`
|
||||
BetaFlag bool `json:"beta"`
|
||||
WebhookHint string `json:"webhook_hint,omitempty"`
|
||||
}
|
||||
|
||||
// IntegrationConnection is one org's link to one provider.
|
||||
type IntegrationConnection struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Provider IntegrationProvider `json:"provider"`
|
||||
Label string `json:"label"`
|
||||
Status IntegrationStatus `json:"status"`
|
||||
DisplayFields json.RawMessage `json:"display_fields"`
|
||||
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Returned only at create time for providers that POST inbound
|
||||
// (Calendly, Cal.com). The dashboard surfaces the resulting URL once.
|
||||
InboundWebhookURL string `json:"inbound_webhook_url,omitempty"`
|
||||
}
|
||||
|
||||
// MeetingBooking represents one booked meeting from Calendly/Cal.com.
|
||||
type MeetingBooking struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Source string `json:"source"`
|
||||
ExternalEventID string `json:"external_event_id"`
|
||||
InviteeEmail string `json:"invitee_email"`
|
||||
InviteeName string `json:"invitee_name"`
|
||||
EventName string `json:"event_name"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
||||
RawPayload json.RawMessage `json:"raw_payload,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// IntegrationRepository owns persistence for third-party integrations.
|
||||
// Connection rows store the encrypted config and inbound secret; meeting
|
||||
// bookings store Calendly/Cal.com conversion events.
|
||||
type IntegrationRepository interface {
|
||||
// Connections
|
||||
UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error
|
||||
ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
|
||||
GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error)
|
||||
GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error)
|
||||
DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error
|
||||
MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error
|
||||
|
||||
// Bookings
|
||||
UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error
|
||||
ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error)
|
||||
}
|
||||
|
||||
type integrationRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewIntegrationRepository(db *pgxpool.Pool) IntegrationRepository {
|
||||
return &integrationRepository{db: db}
|
||||
}
|
||||
|
||||
// UpsertConnection inserts a new connection or updates an existing
|
||||
// (org, provider, label) tuple. Encrypted config and inbound secret are
|
||||
// only written when non-nil, so partial updates do not blow away the rest
|
||||
// of the config.
|
||||
func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt = now
|
||||
c.UpdatedAt = now
|
||||
|
||||
display := c.DisplayFields
|
||||
if len(display) == 0 {
|
||||
display = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO integration_connections (
|
||||
id, organization_id, provider, label, status,
|
||||
inbound_secret, config_encrypted, display_fields,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||
ON CONFLICT (organization_id, provider, label) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
inbound_secret = COALESCE(EXCLUDED.inbound_secret, integration_connections.inbound_secret),
|
||||
config_encrypted = COALESCE(EXCLUDED.config_encrypted, integration_connections.config_encrypted),
|
||||
display_fields = EXCLUDED.display_fields,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status),
|
||||
nullIfEmptyStr(inboundSecret), nullIfEmptyBytes(configEncrypted), display, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfEmptyStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullIfEmptyBytes(b []byte) any {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *integrationRepository) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, organization_id, provider, label, status, display_fields,
|
||||
last_synced_at, last_error, last_error_at, created_at, updated_at
|
||||
FROM integration_connections
|
||||
WHERE organization_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.IntegrationConnection{}
|
||||
for rows.Next() {
|
||||
c, err := scanConnection(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *integrationRepository) GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error) {
|
||||
row := r.db.QueryRow(ctx, `
|
||||
SELECT id, organization_id, provider, label, status, display_fields,
|
||||
last_synced_at, last_error, last_error_at, created_at, updated_at
|
||||
FROM integration_connections
|
||||
WHERE organization_id = $1 AND provider = $2 AND label = $3
|
||||
`, orgID, string(provider), label)
|
||||
c, err := scanConnection(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// GetConnectionByInboundSecret resolves the connection an incoming webhook
|
||||
// belongs to. Callers must validate the secret out-of-band (e.g. Calendly
|
||||
// signature). This lookup is the org-routing step.
|
||||
func (r *integrationRepository) GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) {
|
||||
if secret == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row := r.db.QueryRow(ctx, `
|
||||
SELECT id, organization_id, provider, label, status, display_fields,
|
||||
last_synced_at, last_error, last_error_at, created_at, updated_at
|
||||
FROM integration_connections
|
||||
WHERE provider = $1 AND inbound_secret = $2
|
||||
LIMIT 1
|
||||
`, string(provider), secret)
|
||||
c, err := scanConnection(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (r *integrationRepository) DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx,
|
||||
`DELETE FROM integration_connections WHERE organization_id = $1 AND id = $2`,
|
||||
orgID, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error {
|
||||
now := time.Now().UTC()
|
||||
if len(displayFields) == 0 {
|
||||
displayFields = json.RawMessage("{}")
|
||||
}
|
||||
if errMsg == "" {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE integration_connections
|
||||
SET status = $1, display_fields = $2, last_synced_at = $3,
|
||||
last_error = NULL, last_error_at = NULL, updated_at = $3
|
||||
WHERE id = $4
|
||||
`, string(status), displayFields, now, id)
|
||||
return err
|
||||
}
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE integration_connections
|
||||
SET status = $1, display_fields = $2,
|
||||
last_error = $3, last_error_at = $4, updated_at = $4
|
||||
WHERE id = $5
|
||||
`, string(status), displayFields, errMsg, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanConnection(row scanner) (*models.IntegrationConnection, error) {
|
||||
var c models.IntegrationConnection
|
||||
var provider, status string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.DisplayFields,
|
||||
&c.LastSyncedAt, &c.LastError, &c.LastErrorAt, &c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Provider = models.IntegrationProvider(provider)
|
||||
c.Status = models.IntegrationStatus(status)
|
||||
if len(c.DisplayFields) == 0 {
|
||||
c.DisplayFields = json.RawMessage("{}")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Meeting bookings
|
||||
|
||||
func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error {
|
||||
if b.ID == uuid.Nil {
|
||||
b.ID = uuid.New()
|
||||
}
|
||||
raw := b.RawPayload
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO meeting_bookings (
|
||||
id, organization_id, source, external_event_id,
|
||||
invitee_email, invitee_name, event_name, scheduled_for,
|
||||
contact_id, campaign_id, raw_payload, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
||||
ON CONFLICT (organization_id, source, external_event_id) DO UPDATE SET
|
||||
invitee_email = EXCLUDED.invitee_email,
|
||||
invitee_name = EXCLUDED.invitee_name,
|
||||
event_name = EXCLUDED.event_name,
|
||||
scheduled_for = EXCLUDED.scheduled_for,
|
||||
contact_id = COALESCE(EXCLUDED.contact_id, meeting_bookings.contact_id),
|
||||
campaign_id = COALESCE(EXCLUDED.campaign_id, meeting_bookings.campaign_id),
|
||||
raw_payload = EXCLUDED.raw_payload
|
||||
`,
|
||||
b.ID, b.OrganizationID, b.Source, b.ExternalEventID,
|
||||
b.InviteeEmail, b.InviteeName, b.EventName, b.ScheduledFor,
|
||||
b.ContactID, b.CampaignID, raw,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, organization_id, source, external_event_id,
|
||||
invitee_email, invitee_name, event_name, scheduled_for,
|
||||
contact_id, campaign_id, created_at
|
||||
FROM meeting_bookings
|
||||
WHERE organization_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, orgID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.MeetingBooking{}
|
||||
for rows.Next() {
|
||||
var b models.MeetingBooking
|
||||
if err := rows.Scan(
|
||||
&b.ID, &b.OrganizationID, &b.Source, &b.ExternalEventID,
|
||||
&b.InviteeEmail, &b.InviteeName, &b.EventName, &b.ScheduledFor,
|
||||
&b.ContactID, &b.CampaignID, &b.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,137 +1,469 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Cloud from '../components/Cloud.astro';
|
||||
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import CTA from '../components/CTA.astro';
|
||||
|
||||
const groups = [
|
||||
// Provider list mirrors internal/app/integration/catalog.go.
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'mailbox',
|
||||
title: 'Mailbox providers',
|
||||
sub: 'Where mail enters and leaves',
|
||||
id: 'crm',
|
||||
eyebrow: 'CRM',
|
||||
headline: 'Sync contacts and activity to your system of record.',
|
||||
body: 'Connect the CRM your team already runs the pipeline in. Replies, bounces, and meeting bookings flow back as timeline events without manual logging.',
|
||||
items: [
|
||||
{ name: 'Google Workspace', body: 'OAuth · Gmail API · FBL signals.', status: 'live' },
|
||||
{ name: 'Microsoft 365', body: 'OAuth · Graph send · IMAP / EWS sync.', status: 'live' },
|
||||
{ name: 'Custom SMTP / IMAP', body: 'Any provider that speaks SMTP / IMAP.', status: 'live' },
|
||||
{ name: 'Apple iCloud', body: 'App-specific password + IMAP / SMTP.', status: 'live' },
|
||||
{ name: 'Zoho Mail', body: 'OAuth + IMAP / SMTP.', status: 'live' },
|
||||
{
|
||||
name: 'HubSpot',
|
||||
tagline: 'Two-way sync for contacts and activities.',
|
||||
body: 'OAuth into HubSpot. Contacts can be pulled into campaigns and outbound activity is written back as engagement events on the contact record.',
|
||||
auth: 'OAuth',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Salesforce',
|
||||
tagline: 'Leads, contacts, and email activity.',
|
||||
body: 'OAuth with the standard Salesforce API. Outbound mail is logged on the lead or contact, and replies fire as task events for the assigned owner.',
|
||||
auth: 'OAuth',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Pipedrive',
|
||||
tagline: 'Persons, deals, and activity timeline.',
|
||||
body: 'Use a Pipedrive API token. Recipients are mapped to persons, replies create activities, and deals can be advanced based on reply intent.',
|
||||
auth: 'API token',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
tagline: 'Leads, contacts, and inbox activity.',
|
||||
body: 'Close API key. Replies and bounces sync as inbox activity, and contacts can be created or updated as campaigns send.',
|
||||
auth: 'API key',
|
||||
beta: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
title: 'CRMs',
|
||||
sub: 'Two-way sync with your system of record',
|
||||
id: 'automation',
|
||||
eyebrow: 'Automation',
|
||||
headline: 'Route Warmbly events through your existing automation stack.',
|
||||
body: 'Generate a Warmbly API token, paste it into your automation platform of choice, and trigger off any event Warmbly emits.',
|
||||
items: [
|
||||
{ name: 'HubSpot', body: 'Two-way contact and activity sync.', status: 'live' },
|
||||
{ name: 'Salesforce', body: 'Leads, contacts, opportunities, custom fields.', status: 'live' },
|
||||
{ name: 'Pipedrive', body: 'Persons, deals, activities.', status: 'live' },
|
||||
{ name: 'Attio', body: 'People, companies, lists.', status: 'beta' },
|
||||
{ name: 'Folk', body: 'Contacts, groups, custom fields.', status: 'beta' },
|
||||
{
|
||||
name: 'Zapier',
|
||||
tagline: 'Triggers and actions across 8,000+ apps.',
|
||||
body: 'A Warmbly API token authenticates the Zapier app. Triggers are available for every event Warmbly emits, and actions cover campaign management, contact updates, and suppression.',
|
||||
auth: 'API token',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Make',
|
||||
tagline: 'Visual automation scenarios.',
|
||||
body: 'Same Warmbly API token, used inside Make scenarios. Useful for branching logic that goes beyond what a flat trigger or action provides.',
|
||||
auth: 'API token',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'n8n',
|
||||
tagline: 'Self-hosted automation workflows.',
|
||||
body: 'For customers who run n8n on their own infrastructure. The same API token authenticates against Warmbly endpoints.',
|
||||
auth: 'API token',
|
||||
beta: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
eyebrow: 'Notifications',
|
||||
headline: 'Pipe replies and bounces into your team chat.',
|
||||
body: 'Positive replies, bounces, complaints, and meeting bookings can fan out to a Slack or Discord channel so the right person sees them without checking the dashboard.',
|
||||
items: [
|
||||
{
|
||||
name: 'Slack',
|
||||
tagline: 'Channels for positive replies, bounces, and meeting bookings.',
|
||||
body: 'Connect Slack with OAuth or paste an incoming-webhook URL. Choose which events post to which channel, with separate channels for replies versus deliverability alerts if needed.',
|
||||
auth: 'OAuth',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
tagline: 'Webhook-based notifications to a server channel.',
|
||||
body: 'Paste a Discord channel webhook URL. The same event filters that apply to Slack apply here, so the two can run in parallel if a team uses both.',
|
||||
auth: 'Webhook URL',
|
||||
beta: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'meetings',
|
||||
eyebrow: 'Meetings',
|
||||
headline: 'Attribute booked meetings to the campaign that surfaced the lead.',
|
||||
body: 'Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in reporting alongside replies.',
|
||||
items: [
|
||||
{
|
||||
name: 'Calendly',
|
||||
tagline: 'invitee.created webhook.',
|
||||
body: 'Paste the URL minted for your organization into Calendly. When a recipient books, the booking is recorded and a campaign.reply_received event fires with trigger=meeting_booked so downstream subscribers see which campaign earned the meeting.',
|
||||
auth: 'Webhook URL',
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Cal.com',
|
||||
tagline: 'BOOKING_CREATED webhook. Cloud and self-hosted.',
|
||||
body: 'The Cal.com payload uses a different shape than Calendly. Both are normalized into one MeetingBooking record so reporting and downstream automations do not need to branch by source.',
|
||||
auth: 'Webhook URL',
|
||||
beta: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: 'Data & enrichment',
|
||||
sub: 'Find, verify, append',
|
||||
eyebrow: 'Data',
|
||||
headline: 'Lead lists in. Status updates out.',
|
||||
body: 'Most lead lists live in a spreadsheet. The Sheets integration reads leads for a campaign and writes send, reply, bounce, and booked events back to the same sheet.',
|
||||
items: [
|
||||
{ name: 'Apollo', body: 'Lead lists pulled directly into Warmbly.', status: 'live' },
|
||||
{ name: 'Clay', body: 'Push enriched lists from Clay tables.', status: 'live' },
|
||||
{ name: 'Clearbit', body: 'Enrich contacts on import.', status: 'live' },
|
||||
{ name: 'Dropcontact', body: 'Email finder and verifier.', status: 'live' },
|
||||
{ name: 'NeverBounce', body: 'Verifier integration on import.', status: 'live' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
title: 'Workflow & alerting',
|
||||
sub: 'Push events into the rest of your stack',
|
||||
items: [
|
||||
{ name: 'Slack', body: 'Channels for positive replies, bounces, quarantines.', status: 'live' },
|
||||
{ name: 'Zapier', body: '70+ triggers and actions.', status: 'live' },
|
||||
{ name: 'Make', body: 'Triggers and actions in scenarios.', status: 'live' },
|
||||
{ name: 'n8n', body: 'Self-hosted workflow integration.', status: 'beta' },
|
||||
{ name: 'Linear', body: 'Create tickets from negative replies.', status: 'soon' },
|
||||
{
|
||||
name: 'Google Sheets',
|
||||
tagline: 'Two-way: read leads, write status updates.',
|
||||
body: 'OAuth a Google account with Sheets scope. Provide a sheet ID. Rows are read starting from row 2 (header convention) and a status column is appended on the right. The same sheet acts as both the lead source and the real-time status report.',
|
||||
auth: 'OAuth',
|
||||
beta: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const statusChip = (s: string) => {
|
||||
if (s === 'live') return 'bg-emerald-50 text-emerald-700';
|
||||
if (s === 'beta') return 'bg-amber-50 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-600';
|
||||
};
|
||||
const statusDot = (s: string) => {
|
||||
if (s === 'live') return 'bg-emerald-500';
|
||||
if (s === 'beta') return 'bg-amber-500';
|
||||
return 'bg-slate-400';
|
||||
};
|
||||
// Real event types from internal/models/webhook.go.
|
||||
const webhookEvents = [
|
||||
{ name: 'campaign.email_sent', desc: 'A campaign step was dispatched to a recipient.' },
|
||||
{ name: 'campaign.email_delivered', desc: 'The receiver acknowledged delivery (250 OK or DSN-equivalent).' },
|
||||
{ name: 'campaign.email_opened', desc: 'Open pixel resolved. Open data is unreliable at major receivers.' },
|
||||
{ name: 'campaign.email_clicked', desc: 'A tracked link was clicked. Deduplicated per recipient.' },
|
||||
{ name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' },
|
||||
{ name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly or Cal.com.' },
|
||||
{ name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP / REMOVE reply.' },
|
||||
{ name: 'campaign.started', desc: 'Campaign moved into the running state.' },
|
||||
{ name: 'campaign.paused', desc: 'Campaign auto-paused on bounce or complaint spike, or paused manually.' },
|
||||
{ name: 'campaign.completed', desc: 'Last sequence step was dispatched for the last recipient.' },
|
||||
{ name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy, watch, throttled, quarantined, or blocked.' },
|
||||
{ name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' },
|
||||
{ name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' },
|
||||
{ name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' },
|
||||
{ name: 'deliverability.bounce', desc: 'External deliverability event was ingested.' },
|
||||
{ name: 'deliverability.complaint', desc: 'External complaint event was ingested.' },
|
||||
{ name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' },
|
||||
{ name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' },
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ n: '01', t: 'Authenticate', d: 'OAuth, an API token, or a webhook URL minted for your organization. The connect drawer surfaces the right method per provider.' },
|
||||
{ n: '02', t: 'Route', d: 'The connection is recorded against your organization. Inbound traffic is routed by the secret in the URL path. Outbound traffic uses the encrypted token.' },
|
||||
{ n: '03', t: 'Live', d: 'Status moves to connected. The dashboard shows the last sync time, the last error if any, and a one-click secret rotation. Disconnect cascades to dependent data.' },
|
||||
];
|
||||
|
||||
const numbers = [
|
||||
{ v: '12', u: 'providers', l: 'CRM, automation, notifications, meetings, data', src: 'integration/catalog.go' },
|
||||
{ v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' },
|
||||
{ v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' },
|
||||
{ v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' },
|
||||
];
|
||||
|
||||
const security = [
|
||||
{ t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' },
|
||||
{ t: 'API tokens stored as opaque blobs', d: 'CRM, automation, and notification keys are never serialized back to the API consumer. The dashboard sees only the public display fields (workspace, channel, account email).' },
|
||||
{ t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' },
|
||||
{ t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' },
|
||||
];
|
||||
|
||||
const faq = [
|
||||
['Do you support OAuth for every provider?',
|
||||
'No. OAuth is used where the provider exposes a per-user identity, such as HubSpot, Salesforce, Google Sheets, and Slack. For account-scoped credentials such as Pipedrive or Close API tokens, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'],
|
||||
['What happens when a token expires or is revoked?',
|
||||
'The connection status moves to degraded and the dashboard shows the provider error. Degraded connections stop attempting new fanout until the credentials are rotated or re-authenticated.'],
|
||||
['Can I have more than one of the same provider?',
|
||||
'Yes. A connection is unique per (organization, provider, label). One organization can hold a HubSpot connection per workspace, or a Slack connection per channel.'],
|
||||
['How fast is the inbound webhook path?',
|
||||
'Calendly and Cal.com POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'],
|
||||
['What if you do not list a provider I need?',
|
||||
'Subscribe to the webhook stream and build the integration directly, or run it through Zapier, Make, or n8n. The eighteen event types cover every state transition that is emitted internally.'],
|
||||
];
|
||||
---
|
||||
<Layout
|
||||
title="Integrations · Warmbly"
|
||||
description="Mailbox providers, CRMs, data enrichment, and workflow tools that plug into Warmbly."
|
||||
description="CRM, automation, notifications, meetings, and data integrations for Warmbly. Plus a signed webhook stream and developer API."
|
||||
>
|
||||
<!-- HERO: with category nav -->
|
||||
<section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);">
|
||||
<div class="absolute top-0 right-0 w-[420px] opacity-30 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;">
|
||||
<Cloud variant={5} opacity={1} />
|
||||
</div>
|
||||
<!-- HERO -->
|
||||
<section class="relative isolate overflow-hidden">
|
||||
<HeroAtmosphere />
|
||||
|
||||
<div class="container-page relative pt-14 pb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-white/55 font-mono mb-3">Integrations</div>
|
||||
<h1 class="text-[32px] md:text-[48px] font-semibold tracking-[-0.025em] leading-[1.06] text-white max-w-3xl">
|
||||
Connect what you already use.
|
||||
<div class="container-page relative pt-16 md:pt-24 pb-20 md:pb-28 text-center">
|
||||
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
||||
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
||||
Integrations
|
||||
</span>
|
||||
Connect your stack to Warmbly
|
||||
</div>
|
||||
|
||||
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
||||
Integrations.
|
||||
</h1>
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
{groups.map((g) => (
|
||||
<a href={`#${g.id}`} class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20">
|
||||
{g.title}
|
||||
<span class="font-mono text-[10.5px] text-white/55">{g.items.length}</span>
|
||||
</a>
|
||||
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
||||
HubSpot, Salesforce, Pipedrive, and Close for CRM. Zapier, Make, and n8n for automation. Slack and Discord for team notifications. Calendly and Cal.com for meeting attribution. Google Sheets for two-way lead lists. Plus a signed webhook stream for everything else.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
||||
<span style="color:#075985;">Get started</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#crm" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
||||
Browse integrations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="border-y border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-3xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">How it works</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
Connect in three steps.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Every provider goes through the same connect drawer. The fields change per provider, the flow does not.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden">
|
||||
<div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55">
|
||||
<span>connect drawer</span>
|
||||
<span>same flow, every provider</span>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10">
|
||||
{steps.map((s) => (
|
||||
<div class="p-7 md:p-9">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span class="font-mono text-[11.5px] text-[#7dd3fc] font-semibold tabular-nums tracking-[0.08em]">{s.n}</span>
|
||||
<span class="text-[18px] md:text-[20px] font-semibold tracking-[-0.02em] text-white">{s.t}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-[13.5px] text-white/65 leading-relaxed">{s.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CATEGORY SECTIONS -->
|
||||
{categories.map((cat, idx) => (
|
||||
<section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}>
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">{cat.eyebrow}</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
{cat.headline}
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">{cat.body}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);">
|
||||
{cat.items.map((it) => (
|
||||
<div class="bg-white p-6 flex flex-col">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-10 h-10 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[14px] font-semibold uppercase">
|
||||
{it.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[15px] font-semibold text-heading">{it.name}</div>
|
||||
<div class="mt-0.5 text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground font-mono">
|
||||
{it.auth}{it.beta && <span class="ml-1.5 text-amber-600">· beta</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] text-foreground/65 leading-relaxed">{it.tagline}</p>
|
||||
<p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/65 leading-relaxed">{it.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<!-- WEBHOOKS + API -->
|
||||
<section class="bg-white py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks & API</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Build your own with a signed event stream.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Eighteen event types, signed with HMAC-SHA256 in the same format Stripe uses, retried with capped exponential backoff, and recorded with full delivery history per endpoint. Subscribe to all events or filter by type.
|
||||
</p>
|
||||
<ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint filter by event type.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts, exponential backoff, full audit trail.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Rotating a secret invalidates the old one immediately.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden">
|
||||
<div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55">
|
||||
<span>POST your webhook endpoint</span>
|
||||
<span>application/json</span>
|
||||
</div>
|
||||
<pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{
|
||||
"id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1",
|
||||
"event_type": "campaign.reply_received",
|
||||
"organization_id": "8c4e7c3d-...-",
|
||||
"created_at": "2026-05-28T14:21:09Z",
|
||||
"data": {
|
||||
"source": "calendly",
|
||||
"invitee_email": "lead@target.co",
|
||||
"event_name": "30 min intro",
|
||||
"scheduled_for": "2026-06-02T16:00:00Z",
|
||||
"contact_id": "c1f...",
|
||||
"booking_id": "b91...",
|
||||
"trigger": "meeting_booked"
|
||||
}
|
||||
}`}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div>
|
||||
<span class="text-[11px] font-mono text-foreground/55 tabular-nums">{webhookEvents.length} live</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--border)] max-h-[420px] overflow-y-auto">
|
||||
{webhookEvents.map((e) => (
|
||||
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,2fr)] gap-4 px-5 py-2.5">
|
||||
<code class="font-mono text-[11.5px] text-[#0369a1] truncate">{e.name}</code>
|
||||
<span class="text-[12px] text-foreground/65 leading-snug">{e.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECURITY -->
|
||||
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-10">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
How credentials are stored.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Integration credentials use the same encryption envelope as mailbox OAuth tokens. Per-user data encryption keys are wrapped by AWS KMS, and plaintext keys are never persisted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);">
|
||||
{security.map((s) => (
|
||||
<div class="bg-white p-6">
|
||||
<Icon name="check" size={14} class="text-[#0284c7]" />
|
||||
<div class="mt-3 text-[15px] font-semibold text-heading">{s.t}</div>
|
||||
<p class="mt-2 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GROUPED INTEGRATION GRIDS -->
|
||||
<section class="py-16 md:py-20">
|
||||
<div class="container-page space-y-16">
|
||||
{groups.map((g) => (
|
||||
<div id={g.id} class="scroll-mt-24">
|
||||
<div class="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">{g.title}</div>
|
||||
<h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading">{g.sub}</h2>
|
||||
</div>
|
||||
<div class="text-[12px] font-mono text-muted-foreground">{g.items.length} · integrations</div>
|
||||
</div>
|
||||
<!-- AT A GLANCE -->
|
||||
<section class="bg-white py-16 md:py-20">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-10">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">At a glance</div>
|
||||
<h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
What the catalog covers.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-5 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);">
|
||||
{g.items.map((it) => (
|
||||
<div class="bg-white p-5 flex flex-col">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="w-10 h-10 rounded-[10px] bg-[color:var(--sky-1)] text-[color:var(--sky-7)] ring-1 ring-[color:var(--sky-2)] inline-flex items-center justify-center text-[15px] font-semibold">
|
||||
{it.name.charAt(0)}
|
||||
</div>
|
||||
<span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${statusChip(it.status)}`}>
|
||||
<span class={`w-1 h-1 rounded-full ${statusDot(it.status)}`}></span>
|
||||
{it.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 text-[14px] font-semibold text-heading">{it.name}</div>
|
||||
<p class="mt-1 text-[12.5px] text-foreground/70 leading-relaxed">{it.body}</p>
|
||||
</div>
|
||||
))}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
||||
{numbers.map((n) => (
|
||||
<div class="bg-white p-5 md:p-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-[26px] md:text-[32px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span>
|
||||
{n.u && <span class="text-[12px] text-muted-foreground">{n.u}</span>}
|
||||
</div>
|
||||
<div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div>
|
||||
<div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[10.5px] font-mono text-muted-foreground truncate">{n.src}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">FAQ</div>
|
||||
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
||||
Common questions.
|
||||
</h2>
|
||||
<p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed">
|
||||
More detail in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
||||
{faq.map(([q, a]) => (
|
||||
<div class="faq-row py-2">
|
||||
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
|
||||
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
|
||||
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
|
||||
<div class="overflow-hidden">
|
||||
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const faq = document.querySelector('[data-faq]');
|
||||
if (!faq) return;
|
||||
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const row = btn.closest('.faq-row');
|
||||
const panel = row.querySelector('.faq-panel');
|
||||
const icon = btn.querySelector('.faq-icon');
|
||||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
|
||||
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CTA
|
||||
title="Need an integration we do not list?"
|
||||
description="We ship the most-requested integrations every quarter. Tell us which one matters."
|
||||
primaryLabel="Request integration"
|
||||
primaryHref="/contact/?topic=integrations"
|
||||
title="Get started with Warmbly."
|
||||
description="Connect your providers, or read the developer docs to build a custom integration."
|
||||
primaryLabel="Get started"
|
||||
primaryHref="https://app.warmbly.com/register"
|
||||
secondaryLabel="See the API"
|
||||
secondaryHref="/developers/"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Drawer that handles per-provider connect inputs. Each provider has a
|
||||
// slightly different set of required fields:
|
||||
// - webhook providers (Calendly, Cal.com): no fields, just a label
|
||||
// - oauth providers (HubSpot, Salesforce, Pipedrive, Google Sheets,
|
||||
// Slack): launch OAuth (we accept a pasted token until OAuth lands)
|
||||
// - api-key providers (Close, Zapier, Make, n8n): paste a token
|
||||
// - webhook-url providers (Discord): paste the channel webhook URL
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type {
|
||||
IntegrationCatalogEntry,
|
||||
IntegrationConnection,
|
||||
} from "@/lib/api/models/app/integrations/Integration";
|
||||
import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
helper?: string;
|
||||
type?: "text" | "password";
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = {
|
||||
calendly: [],
|
||||
cal_com: [],
|
||||
|
||||
hubspot: [
|
||||
{ key: "workspace", label: "Workspace name", placeholder: "Acme" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
|
||||
helper: "Paste a token with crm.objects.contacts read/write scope." },
|
||||
],
|
||||
salesforce: [
|
||||
{ key: "workspace", label: "Org name", placeholder: "Acme Salesforce" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
|
||||
helper: "Paste a Salesforce session ID or OAuth bearer." },
|
||||
],
|
||||
pipedrive: [
|
||||
{ key: "workspace", label: "Company name", placeholder: "Acme" },
|
||||
{ key: "api_token", label: "API token", type: "password", required: true,
|
||||
helper: "Settings → Personal → API in your Pipedrive account." },
|
||||
],
|
||||
close: [
|
||||
{ key: "workspace", label: "Organization", placeholder: "Acme" },
|
||||
{ key: "api_token", label: "API key", type: "password", required: true,
|
||||
helper: "Settings → Developer in Close." },
|
||||
],
|
||||
|
||||
zapier: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into Zapier when prompted." },
|
||||
],
|
||||
make: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into Make when prompted." },
|
||||
],
|
||||
n8n: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into n8n when prompted." },
|
||||
],
|
||||
|
||||
slack: [
|
||||
{ key: "workspace", label: "Workspace name", placeholder: "Acme Slack" },
|
||||
{ key: "channel", label: "Channel", placeholder: "#sales" },
|
||||
{ key: "webhook_url", label: "Incoming-webhook URL", type: "password", required: true,
|
||||
helper: "Create an incoming webhook in your Slack admin and paste it here." },
|
||||
],
|
||||
discord: [
|
||||
{ key: "server", label: "Server name", placeholder: "Acme" },
|
||||
{ key: "webhook_url", label: "Channel webhook URL", type: "password", required: true,
|
||||
helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL." },
|
||||
],
|
||||
|
||||
google_sheets: [
|
||||
{ key: "sheet_id", label: "Sheet ID", placeholder: "1AbC...XyZ", required: true,
|
||||
helper: "The long ID in the sheet's URL between /d/ and /edit." },
|
||||
{ key: "sheet_title", label: "Display label", placeholder: "Q2 outbound list" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password",
|
||||
helper: "Paste a token with Sheets scope. OAuth wiring lands in onboarding." },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ConnectDrawer({
|
||||
entry,
|
||||
onClose,
|
||||
onConnected,
|
||||
}: {
|
||||
entry: IntegrationCatalogEntry;
|
||||
onClose: () => void;
|
||||
onConnected: (c: IntegrationConnection) => void;
|
||||
}) {
|
||||
const [label, setLabel] = React.useState("");
|
||||
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||||
const connect = useConnectIntegration();
|
||||
|
||||
const fields = FIELDS_BY_PROVIDER[entry.provider] ?? [];
|
||||
|
||||
function update(key: string, value: string) {
|
||||
setConfig((c) => ({ ...c, [key]: value }));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
for (const f of fields) {
|
||||
if (f.required && !config[f.key]?.trim()) {
|
||||
toast.error(`${f.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = await connect.mutateAsync({
|
||||
provider: entry.provider,
|
||||
label: label.trim() || entry.name,
|
||||
config,
|
||||
});
|
||||
toast.success(`Connected to ${entry.name}`);
|
||||
onConnected(conn);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(e.response?.data?.error ?? e.message ?? "Connect failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="ml-auto h-full w-[480px] bg-white shadow-xl flex flex-col z-10 relative">
|
||||
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
|
||||
<div className="w-7 h-7 rounded bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[12px] font-semibold uppercase">
|
||||
{entry.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div>
|
||||
<div className="text-[12.5px] text-slate-900 font-medium truncate">{entry.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="h-7 w-7 rounded border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex-1 overflow-auto flex flex-col">
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p>
|
||||
|
||||
<div>
|
||||
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
|
||||
Label (optional)
|
||||
</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={entry.name}
|
||||
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 transition-colors"
|
||||
/>
|
||||
<p className="text-[10.5px] text-slate-400 mt-1">
|
||||
Useful if you connect more than one of the same provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
|
||||
{f.label}
|
||||
{f.required && <span className="text-rose-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type={f.type ?? "text"}
|
||||
value={config[f.key] ?? ""}
|
||||
onChange={(e) => update(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors"
|
||||
/>
|
||||
{f.helper && (
|
||||
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||
<p className="text-[12px] text-slate-600 leading-relaxed">
|
||||
{entry.webhook_hint ?? "We will mint a URL for you on the next screen. Paste it into the provider's webhook configuration."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-7 px-3 rounded border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={connect.isPending}
|
||||
className={cn(
|
||||
"h-7 px-3 rounded text-[12px] font-medium text-white transition-colors",
|
||||
connect.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700",
|
||||
)}
|
||||
>
|
||||
{connect.isPending ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Modal that surfaces the per-org inbound webhook URL exactly once after
|
||||
// a Calendly/Cal.com/DMARC connection is created. The secret is embedded
|
||||
// in the URL path — if the user closes without copying, they must
|
||||
// rotate the secret to get it again.
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { IntegrationProvider } from "@/lib/api/models/app/integrations/Integration";
|
||||
|
||||
const PROVIDER_NAMES: Partial<Record<IntegrationProvider, string>> = {
|
||||
calendly: "Calendly",
|
||||
cal_com: "Cal.com",
|
||||
};
|
||||
|
||||
const HINTS: Partial<Record<IntegrationProvider, string>> = {
|
||||
calendly: "Paste this in Calendly under Account, Integrations, Webhooks, Create Webhook. Subscribe to invitee.created.",
|
||||
cal_com: "Paste this in Cal.com under Settings, Developer, Webhooks. Subscribe to BOOKING_CREATED.",
|
||||
};
|
||||
|
||||
export default function InboundUrlDialog({
|
||||
provider,
|
||||
url,
|
||||
onClose,
|
||||
}: {
|
||||
provider: IntegrationProvider;
|
||||
url: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const fullUrl = url.startsWith("http")
|
||||
? url
|
||||
: `${window.location.origin}${url}`;
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(fullUrl).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
() => toast.error("Failed to copy"),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="relative z-10 w-[520px] bg-white rounded-lg shadow-xl border border-slate-200 overflow-hidden">
|
||||
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Webhook URL</div>
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
<div className="text-[12.5px] text-slate-900 font-medium truncate flex-1">
|
||||
{PROVIDER_NAMES[provider] ?? provider}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="h-7 w-7 rounded border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<p className="text-[12.5px] text-slate-600 leading-relaxed">
|
||||
{HINTS[provider] ?? "Paste this URL in the provider's webhook configuration."}
|
||||
</p>
|
||||
|
||||
<div className="rounded border border-slate-200 bg-slate-50 p-2.5 flex items-center gap-2">
|
||||
<code className="flex-1 font-mono text-[11.5px] text-slate-800 break-all">{fullUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="h-7 px-2 rounded border border-slate-200 bg-white hover:border-slate-300 text-slate-700 inline-flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-3 h-3 text-emerald-600" /> : <CopyIcon className="w-3 h-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5">
|
||||
<p className="text-[11.5px] text-amber-900 leading-relaxed">
|
||||
This URL contains a secret that is only shown once. If you lose it, rotate the
|
||||
connection to mint a new one — the old URL will stop working immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-7 px-3 rounded bg-slate-900 text-white text-[12px] font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Integrations dashboard.
|
||||
//
|
||||
// One page covers the integration surface: catalog of available providers
|
||||
// (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack,
|
||||
// Discord, Calendly, Cal.com, Google Sheets), per-org connection state,
|
||||
// inbound webhook URLs, and meeting bookings.
|
||||
//
|
||||
// Layout follows the Page primitives: stat strip across the top, section
|
||||
// bars between zones, no max-width chrome. Connect / disconnect happens
|
||||
// in an inline drawer so the page stays a single navigation target from
|
||||
// the sidebar.
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CableIcon,
|
||||
CalendarCheckIcon,
|
||||
CheckIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
EmptyBlock,
|
||||
Page,
|
||||
PageBody,
|
||||
PageTopbar,
|
||||
SectionBar,
|
||||
Stat,
|
||||
StatStrip,
|
||||
} from "@/components/layout/Page";
|
||||
import useIntegrationCatalog from "@/lib/api/hooks/app/integrations/useIntegrationCatalog";
|
||||
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
|
||||
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
|
||||
import useMeetingBookings from "@/lib/api/hooks/app/integrations/useMeetingBookings";
|
||||
import type {
|
||||
IntegrationCatalogEntry,
|
||||
IntegrationCategory,
|
||||
IntegrationConnection,
|
||||
IntegrationProvider,
|
||||
} from "@/lib/api/models/app/integrations/Integration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import ConnectDrawer from "./_components/ConnectDrawer";
|
||||
import InboundUrlDialog from "./_components/InboundUrlDialog";
|
||||
|
||||
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
|
||||
crm: "CRM",
|
||||
automation: "Automation",
|
||||
notifications: "Notifications",
|
||||
meetings: "Meetings",
|
||||
data: "Data",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: IntegrationCategory[] = ["crm", "automation", "notifications", "meetings", "data"];
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const catalogQuery = useIntegrationCatalog();
|
||||
const connectionsQuery = useIntegrationConnections();
|
||||
const bookingsQuery = useMeetingBookings();
|
||||
|
||||
const disconnect = useDisconnectIntegration();
|
||||
|
||||
const [connectTarget, setConnectTarget] = React.useState<IntegrationCatalogEntry | null>(null);
|
||||
const [inboundUrl, setInboundUrl] = React.useState<{ provider: IntegrationProvider; url: string } | null>(null);
|
||||
|
||||
const catalog = catalogQuery.data?.catalog ?? [];
|
||||
const connections = connectionsQuery.data?.connections ?? [];
|
||||
const bookings = bookingsQuery.data?.bookings ?? [];
|
||||
|
||||
const byProvider = React.useMemo(() => {
|
||||
const m: Record<string, IntegrationConnection[]> = {};
|
||||
for (const c of connections) {
|
||||
(m[c.provider] ??= []).push(c);
|
||||
}
|
||||
return m;
|
||||
}, [connections]);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const map: Partial<Record<IntegrationCategory, IntegrationCatalogEntry[]>> = {};
|
||||
for (const entry of catalog) {
|
||||
(map[entry.category] ??= []).push(entry);
|
||||
}
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
const connectedCount = connections.filter((c) => c.status === "connected").length;
|
||||
const degradedCount = connections.filter((c) => c.status === "degraded").length;
|
||||
|
||||
function refreshAll() {
|
||||
catalogQuery.refetch();
|
||||
connectionsQuery.refetch();
|
||||
bookingsQuery.refetch();
|
||||
}
|
||||
|
||||
async function handleDisconnect(connection: IntegrationConnection) {
|
||||
try {
|
||||
await disconnect.mutateAsync(connection.id);
|
||||
toast.success("Disconnected");
|
||||
} catch {
|
||||
toast.error("Disconnect failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTopbar eyebrow="Integrations" subtitle="CRMs, automation, notifications, meetings, and data">
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} />
|
||||
</button>
|
||||
</PageTopbar>
|
||||
|
||||
<StatStrip cols={4}>
|
||||
<Stat
|
||||
label="Catalog"
|
||||
value={catalog.length}
|
||||
sub="available providers"
|
||||
/>
|
||||
<Stat
|
||||
label="Connected"
|
||||
value={connectedCount}
|
||||
sub={`${connections.length} total`}
|
||||
accent={connectedCount > 0}
|
||||
/>
|
||||
<Stat
|
||||
label="Degraded"
|
||||
value={degradedCount}
|
||||
sub={degradedCount > 0 ? "needs attention" : "all healthy"}
|
||||
/>
|
||||
<Stat
|
||||
label="Meetings"
|
||||
value={bookings.length}
|
||||
sub="from Calendly + Cal.com"
|
||||
last
|
||||
/>
|
||||
</StatStrip>
|
||||
|
||||
<PageBody>
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const entries = grouped[category] ?? [];
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<section key={category}>
|
||||
<SectionBar label={CATEGORY_LABELS[category]} count={entries.length} />
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
|
||||
{entries.map((entry) => (
|
||||
<CatalogCard
|
||||
key={entry.provider}
|
||||
entry={entry}
|
||||
connections={byProvider[entry.provider] ?? []}
|
||||
onConnect={() => setConnectTarget(entry)}
|
||||
onDisconnect={handleDisconnect}
|
||||
onShowInbound={(url) => setInboundUrl({ provider: entry.provider, url })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<SectionBar label="Meeting bookings" count={bookings.length}>
|
||||
<CalendarCheckIcon className="w-3 h-3 text-slate-400" />
|
||||
</SectionBar>
|
||||
{bookings.length === 0 ? (
|
||||
<EmptyBlock
|
||||
title="No meetings booked yet"
|
||||
body="Connect Calendly or Cal.com to track which campaigns lead to meetings."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200/60 border-b border-slate-200/60">
|
||||
{bookings.slice(0, 10).map((b) => (
|
||||
<div key={b.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]">
|
||||
<SourceDot source={b.source} />
|
||||
<span className="font-medium text-slate-900 truncate w-60">{b.invitee_email}</span>
|
||||
<span className="text-slate-500 truncate flex-1">{b.event_name}</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
|
||||
{b.scheduled_for ? new Date(b.scheduled_for).toLocaleString() : "tbd"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
|
||||
{connectTarget && (
|
||||
<ConnectDrawer
|
||||
entry={connectTarget}
|
||||
onClose={() => setConnectTarget(null)}
|
||||
onConnected={(conn) => {
|
||||
if (conn.inbound_webhook_url) {
|
||||
setInboundUrl({ provider: conn.provider, url: conn.inbound_webhook_url });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboundUrl && (
|
||||
<InboundUrlDialog
|
||||
provider={inboundUrl.provider}
|
||||
url={inboundUrl.url}
|
||||
onClose={() => setInboundUrl(null)}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
entry,
|
||||
connections,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onShowInbound,
|
||||
}: {
|
||||
entry: IntegrationCatalogEntry;
|
||||
connections: IntegrationConnection[];
|
||||
onConnect: () => void;
|
||||
onDisconnect: (c: IntegrationConnection) => void;
|
||||
onShowInbound: (url: string) => void;
|
||||
}) {
|
||||
const connected = connections.length > 0;
|
||||
const status = connected ? connections[0].status : "disconnected";
|
||||
return (
|
||||
<div className="bg-white p-5 flex flex-col min-h-[140px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-9 h-9 rounded-md bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[13px] font-semibold uppercase">
|
||||
{entry.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-slate-900 truncate">{entry.name}</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-mono">
|
||||
{entry.auth_method}
|
||||
{entry.beta && (
|
||||
<span className="ml-1.5 text-amber-600">· beta</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-3">
|
||||
{entry.tagline}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-3 flex items-center justify-between gap-2">
|
||||
{entry.docs_url ? (
|
||||
<a
|
||||
href={entry.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-slate-500 hover:text-sky-700 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
) : <span />}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{connected && connections[0].id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDisconnect(connections[0])}
|
||||
className="h-6 px-2 rounded text-[11px] text-slate-500 hover:text-rose-700 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
{connections[0].display_fields && Object.keys(connections[0].display_fields).length > 0 && (
|
||||
<span className="font-mono text-[10px] text-slate-400 truncate max-w-[120px]">
|
||||
{(connections[0].display_fields as Record<string, string>)["workspace"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["sheet_title"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["account_email"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["channel"] ??
|
||||
""}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected && entry.webhook_hint && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onShowInbound("/api/v1/integrations/inbound/" + entry.provider.replace("_", "-") + "/<your-secret>");
|
||||
}}
|
||||
className="mt-2 text-[10.5px] text-sky-700 hover:underline self-start inline-flex items-center gap-1"
|
||||
>
|
||||
<CableIcon className="w-3 h-3" />
|
||||
Webhook URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === "connected"
|
||||
? { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" }
|
||||
: status === "degraded"
|
||||
? { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" }
|
||||
: status === "pending"
|
||||
? { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500" }
|
||||
: { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400" };
|
||||
const label = status === "disconnected" ? "not connected" : status;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium",
|
||||
tone.bg,
|
||||
tone.text,
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", tone.dot)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDot({ source }: { source: string }) {
|
||||
const colour = source === "calendly" ? "bg-rose-400" : "bg-indigo-400";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn("size-1.5 rounded-full", colour)} />
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">
|
||||
{source === "calendly" ? "calendly" : "cal.com"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
void CheckIcon;
|
||||
void XIcon;
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3Icon,
|
||||
CableIcon,
|
||||
CheckSquareIcon,
|
||||
CircleDollarSignIcon,
|
||||
FileTextIcon,
|
||||
@@ -87,6 +88,7 @@ const sections: NavSection[] = [
|
||||
label: "Resources",
|
||||
items: [
|
||||
{ title: "Templates", url: "/app/templates", icon: FileTextIcon },
|
||||
{ title: "Integrations", url: "/app/integrations", icon: CableIcon },
|
||||
{ title: "API Keys", url: "/app/api-keys", icon: KeyIcon },
|
||||
{ title: "Audit log", url: "/app/audit", icon: ListChecksIcon, rolesAllowed: "manage" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IntegrationConnection, IntegrationProvider } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
export interface ConnectInput {
|
||||
provider: IntegrationProvider;
|
||||
label?: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default async function connectIntegration(input: ConnectInput): Promise<IntegrationConnection> {
|
||||
return await Request<IntegrationConnection>({
|
||||
method: "POST",
|
||||
url: "/integrations/connections",
|
||||
data: input,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Request from "../../Request";
|
||||
|
||||
export default async function disconnectIntegration(id: string): Promise<void> {
|
||||
await Request<void>({
|
||||
method: "DELETE",
|
||||
url: `/integrations/connections/${id}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IntegrationCatalogEntry } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
export default async function listIntegrationCatalog(): Promise<{ catalog: IntegrationCatalogEntry[] }> {
|
||||
return await Request<{ catalog: IntegrationCatalogEntry[] }>({
|
||||
method: "GET",
|
||||
url: "/integrations/catalog",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IntegrationConnection } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
export default async function listIntegrationConnections(): Promise<{ connections: IntegrationConnection[] }> {
|
||||
return await Request<{ connections: IntegrationConnection[] }>({
|
||||
method: "GET",
|
||||
url: "/integrations/connections",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MeetingBooking } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
export default async function listMeetingBookings(): Promise<{ bookings: MeetingBooking[] }> {
|
||||
return await Request<{ bookings: MeetingBooking[] }>({
|
||||
method: "GET",
|
||||
url: "/integrations/bookings",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import connectIntegration, { type ConnectInput } from "@/lib/api/client/app/integrations/connectIntegration";
|
||||
|
||||
export default function useConnectIntegration() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: ConnectInput) => connectIntegration(input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import disconnectIntegration from "@/lib/api/client/app/integrations/disconnectIntegration";
|
||||
|
||||
export default function useDisconnectIntegration() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => disconnectIntegration(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import listIntegrationCatalog from "@/lib/api/client/app/integrations/listCatalog";
|
||||
|
||||
export default function useIntegrationCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ["integrations", "catalog"],
|
||||
queryFn: listIntegrationCatalog,
|
||||
// Catalog is effectively static — refresh once an hour at most.
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import listIntegrationConnections from "@/lib/api/client/app/integrations/listConnections";
|
||||
|
||||
export default function useIntegrationConnections() {
|
||||
return useQuery({
|
||||
queryKey: ["integrations", "connections"],
|
||||
queryFn: listIntegrationConnections,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import listMeetingBookings from "@/lib/api/client/app/integrations/listMeetingBookings";
|
||||
|
||||
export default function useMeetingBookings() {
|
||||
return useQuery({
|
||||
queryKey: ["integrations", "bookings"],
|
||||
queryFn: listMeetingBookings,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Mirror of the backend's models/integration.go shapes. Only the fields
|
||||
// the dashboard renders are typed; opaque blobs like display_fields stay
|
||||
// generic so the UI can dig in without round-trips to the type system.
|
||||
|
||||
export type IntegrationProvider =
|
||||
| "hubspot"
|
||||
| "salesforce"
|
||||
| "pipedrive"
|
||||
| "close"
|
||||
| "zapier"
|
||||
| "make"
|
||||
| "n8n"
|
||||
| "slack"
|
||||
| "discord"
|
||||
| "calendly"
|
||||
| "cal_com"
|
||||
| "google_sheets";
|
||||
|
||||
export type IntegrationStatus = "pending" | "connected" | "degraded" | "disconnected";
|
||||
|
||||
export type IntegrationCategory =
|
||||
| "crm"
|
||||
| "automation"
|
||||
| "notifications"
|
||||
| "meetings"
|
||||
| "data";
|
||||
|
||||
export interface IntegrationCatalogEntry {
|
||||
provider: IntegrationProvider;
|
||||
name: string;
|
||||
tagline: string;
|
||||
category: IntegrationCategory;
|
||||
docs_url?: string;
|
||||
auth_method: "oauth" | "api_key" | "webhook";
|
||||
badge_color?: string;
|
||||
beta: boolean;
|
||||
webhook_hint?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationConnection {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
provider: IntegrationProvider;
|
||||
label: string;
|
||||
status: IntegrationStatus;
|
||||
display_fields: Record<string, unknown>;
|
||||
last_synced_at?: string | null;
|
||||
last_error?: string | null;
|
||||
last_error_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
/** Returned once at create time for inbound-webhook providers. */
|
||||
inbound_webhook_url?: string;
|
||||
}
|
||||
|
||||
export interface MeetingBooking {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
source: "calendly" | "cal_com";
|
||||
external_event_id: string;
|
||||
invitee_email: string;
|
||||
invitee_name: string;
|
||||
event_name: string;
|
||||
scheduled_for?: string;
|
||||
contact_id?: string;
|
||||
campaign_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import DealsPage from './app/app/crm/deals/page';
|
||||
import TasksPage from './app/app/crm/tasks/page';
|
||||
import TemplatesPage from './app/app/templates/page';
|
||||
import APIKeysPage from './app/app/api-keys/page';
|
||||
import IntegrationsPage from './app/app/integrations/page';
|
||||
import AuditPage from './app/app/audit/page';
|
||||
import SettingsLayout from './app/app/settings/layout';
|
||||
import ProfileSettingsPage from './app/app/settings/profile/page';
|
||||
@@ -247,6 +248,10 @@ const router = createBrowserRouter([
|
||||
path: "api-keys",
|
||||
element: <APIKeysPage />,
|
||||
},
|
||||
{
|
||||
path: "integrations",
|
||||
element: <IntegrationsPage />,
|
||||
},
|
||||
{
|
||||
path: "audit",
|
||||
element: <AuditPage />,
|
||||
|
||||
Reference in New Issue
Block a user