Merge pull request #17 from warmbly/feature/integrations

refactor(site,web): align integrations surfaces with new catalog
This commit is contained in:
Matthew Meszaros
2026-05-28 09:57:17 +02:00
committed by GitHub
29 changed files with 2709 additions and 97 deletions
+11
View File
@@ -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
+6
View File
@@ -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
+185
View File
@@ -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})
}
+19
View File
@@ -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.
+191
View File
@@ -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
}
+127
View File
@@ -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,
},
}
}
+145
View File
@@ -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))
}
+227
View File
@@ -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);
+134
View File
@@ -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"`
}
+266
View File
@@ -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()
}
+429 -97
View File
@@ -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 &amp; 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=&lt;unix&gt;,v1=&lt;hex&gt;</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>
);
}
+351
View File
@@ -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;
+2
View File
@@ -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;
}
+5
View File
@@ -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 />,