diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 2567f129..282e906d 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -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 diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index e4d81a7e..761a4743 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -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 diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go new file mode 100644 index 00000000..b45629a3 --- /dev/null +++ b/internal/api/handler/integration.go @@ -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}) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 73721bf4..091f3bb2 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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. diff --git a/internal/app/integration/calendly.go b/internal/app/integration/calendly.go new file mode 100644 index 00000000..e1bbbd39 --- /dev/null +++ b/internal/app/integration/calendly.go @@ -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 +} diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go new file mode 100644 index 00000000..a966735d --- /dev/null +++ b/internal/app/integration/catalog.go @@ -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, + }, + } +} diff --git a/internal/app/integration/google_sheets.go b/internal/app/integration/google_sheets.go new file mode 100644 index 00000000..9a7780af --- /dev/null +++ b/internal/app/integration/google_sheets.go @@ -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 ". 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)) +} diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go new file mode 100644 index 00000000..eec3229c --- /dev/null +++ b/internal/app/integration/service.go @@ -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 +} diff --git a/internal/infrastructure/db/migrations/000044_integrations.down.sql b/internal/infrastructure/db/migrations/000044_integrations.down.sql new file mode 100644 index 00000000..b54b8a5a --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_integrations.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS meeting_bookings; +DROP TABLE IF EXISTS integration_connections; diff --git a/internal/infrastructure/db/migrations/000044_integrations.up.sql b/internal/infrastructure/db/migrations/000044_integrations.up.sql new file mode 100644 index 00000000..7c16b72d --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_integrations.up.sql @@ -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); diff --git a/internal/models/integration.go b/internal/models/integration.go new file mode 100644 index 00000000..7339da92 --- /dev/null +++ b/internal/models/integration.go @@ -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"` +} diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go new file mode 100644 index 00000000..0edb56b3 --- /dev/null +++ b/internal/repository/pg_integration.go @@ -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() +} diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 930d8c8b..a95cd844 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -1,137 +1,469 @@ --- import Layout from '../layouts/Layout.astro'; -import Cloud from '../components/Cloud.astro'; +import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -const groups = [ +// Provider list mirrors internal/app/integration/catalog.go. + +const categories = [ { - id: 'mailbox', - title: 'Mailbox providers', - sub: 'Where mail enters and leaves', + id: 'crm', + eyebrow: 'CRM', + headline: 'Sync contacts and activity to your system of record.', + body: 'Connect the CRM your team already runs the pipeline in. Replies, bounces, and meeting bookings flow back as timeline events without manual logging.', items: [ - { name: 'Google Workspace', body: 'OAuth · Gmail API · FBL signals.', status: 'live' }, - { name: 'Microsoft 365', body: 'OAuth · Graph send · IMAP / EWS sync.', status: 'live' }, - { name: 'Custom SMTP / IMAP', body: 'Any provider that speaks SMTP / IMAP.', status: 'live' }, - { name: 'Apple iCloud', body: 'App-specific password + IMAP / SMTP.', status: 'live' }, - { name: 'Zoho Mail', body: 'OAuth + IMAP / SMTP.', status: 'live' }, + { + name: 'HubSpot', + tagline: 'Two-way sync for contacts and activities.', + body: 'OAuth into HubSpot. Contacts can be pulled into campaigns and outbound activity is written back as engagement events on the contact record.', + auth: 'OAuth', + beta: false, + }, + { + name: 'Salesforce', + tagline: 'Leads, contacts, and email activity.', + body: 'OAuth with the standard Salesforce API. Outbound mail is logged on the lead or contact, and replies fire as task events for the assigned owner.', + auth: 'OAuth', + beta: false, + }, + { + name: 'Pipedrive', + tagline: 'Persons, deals, and activity timeline.', + body: 'Use a Pipedrive API token. Recipients are mapped to persons, replies create activities, and deals can be advanced based on reply intent.', + auth: 'API token', + beta: false, + }, + { + name: 'Close', + tagline: 'Leads, contacts, and inbox activity.', + body: 'Close API key. Replies and bounces sync as inbox activity, and contacts can be created or updated as campaigns send.', + auth: 'API key', + beta: false, + }, ], }, { - id: 'crm', - title: 'CRMs', - sub: 'Two-way sync with your system of record', + id: 'automation', + eyebrow: 'Automation', + headline: 'Route Warmbly events through your existing automation stack.', + body: 'Generate a Warmbly API token, paste it into your automation platform of choice, and trigger off any event Warmbly emits.', items: [ - { name: 'HubSpot', body: 'Two-way contact and activity sync.', status: 'live' }, - { name: 'Salesforce', body: 'Leads, contacts, opportunities, custom fields.', status: 'live' }, - { name: 'Pipedrive', body: 'Persons, deals, activities.', status: 'live' }, - { name: 'Attio', body: 'People, companies, lists.', status: 'beta' }, - { name: 'Folk', body: 'Contacts, groups, custom fields.', status: 'beta' }, + { + name: 'Zapier', + tagline: 'Triggers and actions across 8,000+ apps.', + body: 'A Warmbly API token authenticates the Zapier app. Triggers are available for every event Warmbly emits, and actions cover campaign management, contact updates, and suppression.', + auth: 'API token', + beta: false, + }, + { + name: 'Make', + tagline: 'Visual automation scenarios.', + body: 'Same Warmbly API token, used inside Make scenarios. Useful for branching logic that goes beyond what a flat trigger or action provides.', + auth: 'API token', + beta: false, + }, + { + name: 'n8n', + tagline: 'Self-hosted automation workflows.', + body: 'For customers who run n8n on their own infrastructure. The same API token authenticates against Warmbly endpoints.', + auth: 'API token', + beta: false, + }, + ], + }, + { + id: 'notifications', + eyebrow: 'Notifications', + headline: 'Pipe replies and bounces into your team chat.', + body: 'Positive replies, bounces, complaints, and meeting bookings can fan out to a Slack or Discord channel so the right person sees them without checking the dashboard.', + items: [ + { + name: 'Slack', + tagline: 'Channels for positive replies, bounces, and meeting bookings.', + body: 'Connect Slack with OAuth or paste an incoming-webhook URL. Choose which events post to which channel, with separate channels for replies versus deliverability alerts if needed.', + auth: 'OAuth', + beta: false, + }, + { + name: 'Discord', + tagline: 'Webhook-based notifications to a server channel.', + body: 'Paste a Discord channel webhook URL. The same event filters that apply to Slack apply here, so the two can run in parallel if a team uses both.', + auth: 'Webhook URL', + beta: false, + }, + ], + }, + { + id: 'meetings', + eyebrow: 'Meetings', + headline: 'Attribute booked meetings to the campaign that surfaced the lead.', + body: 'Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in reporting alongside replies.', + items: [ + { + name: 'Calendly', + tagline: 'invitee.created webhook.', + body: 'Paste the URL minted for your organization into Calendly. When a recipient books, the booking is recorded and a campaign.reply_received event fires with trigger=meeting_booked so downstream subscribers see which campaign earned the meeting.', + auth: 'Webhook URL', + beta: false, + }, + { + name: 'Cal.com', + tagline: 'BOOKING_CREATED webhook. Cloud and self-hosted.', + body: 'The Cal.com payload uses a different shape than Calendly. Both are normalized into one MeetingBooking record so reporting and downstream automations do not need to branch by source.', + auth: 'Webhook URL', + beta: false, + }, ], }, { id: 'data', - title: 'Data & enrichment', - sub: 'Find, verify, append', + eyebrow: 'Data', + headline: 'Lead lists in. Status updates out.', + body: 'Most lead lists live in a spreadsheet. The Sheets integration reads leads for a campaign and writes send, reply, bounce, and booked events back to the same sheet.', items: [ - { name: 'Apollo', body: 'Lead lists pulled directly into Warmbly.', status: 'live' }, - { name: 'Clay', body: 'Push enriched lists from Clay tables.', status: 'live' }, - { name: 'Clearbit', body: 'Enrich contacts on import.', status: 'live' }, - { name: 'Dropcontact', body: 'Email finder and verifier.', status: 'live' }, - { name: 'NeverBounce', body: 'Verifier integration on import.', status: 'live' }, - ], - }, - { - id: 'workflow', - title: 'Workflow & alerting', - sub: 'Push events into the rest of your stack', - items: [ - { name: 'Slack', body: 'Channels for positive replies, bounces, quarantines.', status: 'live' }, - { name: 'Zapier', body: '70+ triggers and actions.', status: 'live' }, - { name: 'Make', body: 'Triggers and actions in scenarios.', status: 'live' }, - { name: 'n8n', body: 'Self-hosted workflow integration.', status: 'beta' }, - { name: 'Linear', body: 'Create tickets from negative replies.', status: 'soon' }, + { + name: 'Google Sheets', + tagline: 'Two-way: read leads, write status updates.', + body: 'OAuth a Google account with Sheets scope. Provide a sheet ID. Rows are read starting from row 2 (header convention) and a status column is appended on the right. The same sheet acts as both the lead source and the real-time status report.', + auth: 'OAuth', + beta: true, + }, ], }, ]; -const statusChip = (s: string) => { - if (s === 'live') return 'bg-emerald-50 text-emerald-700'; - if (s === 'beta') return 'bg-amber-50 text-amber-700'; - return 'bg-slate-100 text-slate-600'; -}; -const statusDot = (s: string) => { - if (s === 'live') return 'bg-emerald-500'; - if (s === 'beta') return 'bg-amber-500'; - return 'bg-slate-400'; -}; +// Real event types from internal/models/webhook.go. +const webhookEvents = [ + { name: 'campaign.email_sent', desc: 'A campaign step was dispatched to a recipient.' }, + { name: 'campaign.email_delivered', desc: 'The receiver acknowledged delivery (250 OK or DSN-equivalent).' }, + { name: 'campaign.email_opened', desc: 'Open pixel resolved. Open data is unreliable at major receivers.' }, + { name: 'campaign.email_clicked', desc: 'A tracked link was clicked. Deduplicated per recipient.' }, + { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' }, + { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly or Cal.com.' }, + { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP / REMOVE reply.' }, + { name: 'campaign.started', desc: 'Campaign moved into the running state.' }, + { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce or complaint spike, or paused manually.' }, + { name: 'campaign.completed', desc: 'Last sequence step was dispatched for the last recipient.' }, + { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy, watch, throttled, quarantined, or blocked.' }, + { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' }, + { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' }, + { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' }, + { name: 'deliverability.bounce', desc: 'External deliverability event was ingested.' }, + { name: 'deliverability.complaint', desc: 'External complaint event was ingested.' }, + { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, + { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, +]; + +const steps = [ + { n: '01', t: 'Authenticate', d: 'OAuth, an API token, or a webhook URL minted for your organization. The connect drawer surfaces the right method per provider.' }, + { n: '02', t: 'Route', d: 'The connection is recorded against your organization. Inbound traffic is routed by the secret in the URL path. Outbound traffic uses the encrypted token.' }, + { n: '03', t: 'Live', d: 'Status moves to connected. The dashboard shows the last sync time, the last error if any, and a one-click secret rotation. Disconnect cascades to dependent data.' }, +]; + +const numbers = [ + { v: '12', u: 'providers', l: 'CRM, automation, notifications, meetings, data', src: 'integration/catalog.go' }, + { v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' }, + { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' }, + { v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' }, +]; + +const security = [ + { t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' }, + { t: 'API tokens stored as opaque blobs', d: 'CRM, automation, and notification keys are never serialized back to the API consumer. The dashboard sees only the public display fields (workspace, channel, account email).' }, + { t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' }, + { t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, +]; + +const faq = [ + ['Do you support OAuth for every provider?', + 'No. OAuth is used where the provider exposes a per-user identity, such as HubSpot, Salesforce, Google Sheets, and Slack. For account-scoped credentials such as Pipedrive or Close API tokens, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'], + ['What happens when a token expires or is revoked?', + 'The connection status moves to degraded and the dashboard shows the provider error. Degraded connections stop attempting new fanout until the credentials are rotated or re-authenticated.'], + ['Can I have more than one of the same provider?', + 'Yes. A connection is unique per (organization, provider, label). One organization can hold a HubSpot connection per workspace, or a Slack connection per channel.'], + ['How fast is the inbound webhook path?', + 'Calendly and Cal.com POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'], + ['What if you do not list a provider I need?', + 'Subscribe to the webhook stream and build the integration directly, or run it through Zapier, Make, or n8n. The eighteen event types cover every state transition that is emitted internally.'], +]; --- <Layout title="Integrations · Warmbly" - description="Mailbox providers, CRMs, data enrichment, and workflow tools that plug into Warmbly." + description="CRM, automation, notifications, meetings, and data integrations for Warmbly. Plus a signed webhook stream and developer API." > - <!-- HERO: with category nav --> - <section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);"> - <div class="absolute top-0 right-0 w-[420px] opacity-30 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;"> - <Cloud variant={5} opacity={1} /> - </div> + <!-- HERO --> + <section class="relative isolate overflow-hidden"> + <HeroAtmosphere /> - <div class="container-page relative pt-14 pb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] text-white/55 font-mono mb-3">Integrations</div> - <h1 class="text-[32px] md:text-[48px] font-semibold tracking-[-0.025em] leading-[1.06] text-white max-w-3xl"> - Connect what you already use. + <div class="container-page relative pt-16 md:pt-24 pb-20 md:pb-28 text-center"> + <div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]"> + <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> + Integrations + </span> + Connect your stack to Warmbly + </div> + + <h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto"> + Integrations. </h1> - <div class="mt-8 flex flex-wrap gap-2"> - {groups.map((g) => ( - <a href={`#${g.id}`} class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> - {g.title} - <span class="font-mono text-[10.5px] text-white/55">{g.items.length}</span> - </a> + <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> + HubSpot, Salesforce, Pipedrive, and Close for CRM. Zapier, Make, and n8n for automation. Slack and Discord for team notifications. Calendly and Cal.com for meeting attribution. Google Sheets for two-way lead lists. Plus a signed webhook stream for everything else. + </p> + + <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> + <a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]"> + <span style="color:#075985;">Get started</span> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> + </svg> + </a> + <a href="#crm" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> + Browse integrations + </a> + </div> + </div> + </section> + + <!-- HOW IT WORKS --> + <section class="border-y border-[color:var(--border)] py-20 md:py-28"> + <div class="container-page"> + <div class="max-w-3xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">How it works</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + Connect in three steps. + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> + Every provider goes through the same connect drawer. The fields change per provider, the flow does not. + </p> + </div> + + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>connect drawer</span> + <span>same flow, every provider</span> + </div> + + <div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10"> + {steps.map((s) => ( + <div class="p-7 md:p-9"> + <div class="flex items-baseline gap-3"> + <span class="font-mono text-[11.5px] text-[#7dd3fc] font-semibold tabular-nums tracking-[0.08em]">{s.n}</span> + <span class="text-[18px] md:text-[20px] font-semibold tracking-[-0.02em] text-white">{s.t}</span> + </div> + <p class="mt-3 text-[13.5px] text-white/65 leading-relaxed">{s.d}</p> + </div> + ))} + </div> + </div> + </div> + </section> + + <!-- CATEGORY SECTIONS --> + {categories.map((cat, idx) => ( + <section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}> + <div class="container-page"> + <div class="max-w-2xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">{cat.eyebrow}</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + {cat.headline} + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">{cat.body}</p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {cat.items.map((it) => ( + <div class="bg-white p-6 flex flex-col"> + <div class="flex items-start justify-between gap-3"> + <div class="flex items-center gap-2.5"> + <div class="w-10 h-10 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[14px] font-semibold uppercase"> + {it.name.charAt(0)} + </div> + <div> + <div class="text-[15px] font-semibold text-heading">{it.name}</div> + <div class="mt-0.5 text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground font-mono"> + {it.auth}{it.beta && <span class="ml-1.5 text-amber-600">· beta</span>} + </div> + </div> + </div> + </div> + <p class="mt-3 text-[13px] text-foreground/65 leading-relaxed">{it.tagline}</p> + <p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/65 leading-relaxed">{it.body}</p> + </div> + ))} + </div> + </div> + </section> + ))} + + <!-- WEBHOOKS + API --> + <section class="bg-white py-20 md:py-28"> + <div class="container-page"> + <div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks & API</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + Build your own with a signed event stream. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + Eighteen event types, signed with HMAC-SHA256 in the same format Stripe uses, retried with capped exponential backoff, and recorded with full delivery history per endpoint. Subscribe to all events or filter by type. + </p> + <ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80"> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint filter by event type.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts, exponential backoff, full audit trail.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Rotating a secret invalidates the old one immediately.</span></li> + </ul> + </div> + + <div class="space-y-5"> + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>POST your webhook endpoint</span> + <span>application/json</span> + </div> + <pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{ + "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1", + "event_type": "campaign.reply_received", + "organization_id": "8c4e7c3d-...-", + "created_at": "2026-05-28T14:21:09Z", + "data": { + "source": "calendly", + "invitee_email": "lead@target.co", + "event_name": "30 min intro", + "scheduled_for": "2026-06-02T16:00:00Z", + "contact_id": "c1f...", + "booking_id": "b91...", + "trigger": "meeting_booked" + } +}`}</code></pre> + </div> + + <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden"> + <div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2"> + <div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div> + <span class="text-[11px] font-mono text-foreground/55 tabular-nums">{webhookEvents.length} live</span> + </div> + <div class="divide-y divide-[color:var(--border)] max-h-[420px] overflow-y-auto"> + {webhookEvents.map((e) => ( + <div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,2fr)] gap-4 px-5 py-2.5"> + <code class="font-mono text-[11.5px] text-[#0369a1] truncate">{e.name}</code> + <span class="text-[12px] text-foreground/65 leading-snug">{e.desc}</span> + </div> + ))} + </div> + </div> + </div> + </div> + </div> + </section> + + <!-- SECURITY --> + <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + How credentials are stored. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + Integration credentials use the same encryption envelope as mailbox OAuth tokens. Per-user data encryption keys are wrapped by AWS KMS, and plaintext keys are never persisted. + </p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {security.map((s) => ( + <div class="bg-white p-6"> + <Icon name="check" size={14} class="text-[#0284c7]" /> + <div class="mt-3 text-[15px] font-semibold text-heading">{s.t}</div> + <p class="mt-2 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p> + </div> ))} </div> </div> </section> - <!-- GROUPED INTEGRATION GRIDS --> - <section class="py-16 md:py-20"> - <div class="container-page space-y-16"> - {groups.map((g) => ( - <div id={g.id} class="scroll-mt-24"> - <div class="flex items-end justify-between mb-6"> - <div> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">{g.title}</div> - <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading">{g.sub}</h2> - </div> - <div class="text-[12px] font-mono text-muted-foreground">{g.items.length} · integrations</div> - </div> + <!-- AT A GLANCE --> + <section class="bg-white py-16 md:py-20"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">At a glance</div> + <h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + What the catalog covers. + </h2> + </div> - <div class="grid sm:grid-cols-2 lg:grid-cols-5 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> - {g.items.map((it) => ( - <div class="bg-white p-5 flex flex-col"> - <div class="flex items-start justify-between"> - <div class="w-10 h-10 rounded-[10px] bg-[color:var(--sky-1)] text-[color:var(--sky-7)] ring-1 ring-[color:var(--sky-2)] inline-flex items-center justify-center text-[15px] font-semibold"> - {it.name.charAt(0)} - </div> - <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${statusChip(it.status)}`}> - <span class={`w-1 h-1 rounded-full ${statusDot(it.status)}`}></span> - {it.status} - </span> - </div> - <div class="mt-4 text-[14px] font-semibold text-heading">{it.name}</div> - <p class="mt-1 text-[12.5px] text-foreground/70 leading-relaxed">{it.body}</p> - </div> - ))} + <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> + {numbers.map((n) => ( + <div class="bg-white p-5 md:p-6"> + <div class="flex items-baseline gap-1"> + <span class="text-[26px] md:text-[32px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span> + {n.u && <span class="text-[12px] text-muted-foreground">{n.u}</span>} + </div> + <div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div> + <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[10.5px] font-mono text-muted-foreground truncate">{n.src}</div> </div> - </div> - ))} + ))} + </div> + </div> + </section> + + <!-- FAQ --> + <section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24"> + <div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">FAQ</div> + <h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading"> + Common questions. + </h2> + <p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed"> + More detail in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. + </p> + </div> + + <div class="divide-y divide-[color:var(--border)]" data-faq> + {faq.map(([q, a]) => ( + <div class="faq-row py-2"> + <button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false"> + <span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span> + <span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg> + </span> + </button> + <div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"> + <div class="overflow-hidden"> + <p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p> + </div> + </div> + </div> + ))} + </div> + + <script is:inline> + (function () { + const faq = document.querySelector('[data-faq]'); + if (!faq) return; + faq.querySelectorAll('.faq-trigger').forEach(function (btn) { + btn.addEventListener('click', function () { + const row = btn.closest('.faq-row'); + const panel = row.querySelector('.faq-panel'); + const icon = btn.querySelector('.faq-icon'); + const open = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', String(!open)); + panel.style.gridTemplateRows = open ? '0fr' : '1fr'; + if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)'; + }); + }); + })(); + </script> </div> </section> <CTA - title="Need an integration we do not list?" - description="We ship the most-requested integrations every quarter. Tell us which one matters." - primaryLabel="Request integration" - primaryHref="/contact/?topic=integrations" + title="Get started with Warmbly." + description="Connect your providers, or read the developer docs to build a custom integration." + primaryLabel="Get started" + primaryHref="https://app.warmbly.com/register" secondaryLabel="See the API" secondaryHref="/developers/" /> diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx new file mode 100644 index 00000000..dab5c4c8 --- /dev/null +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -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> + ); +} diff --git a/web/src/app/app/integrations/_components/InboundUrlDialog.tsx b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx new file mode 100644 index 00000000..5203cf33 --- /dev/null +++ b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx @@ -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> + ); +} diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx new file mode 100644 index 00000000..888d0c02 --- /dev/null +++ b/web/src/app/app/integrations/page.tsx @@ -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; diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index f7549cee..705fa242 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -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" }, ], diff --git a/web/src/lib/api/client/app/integrations/connectIntegration.ts b/web/src/lib/api/client/app/integrations/connectIntegration.ts new file mode 100644 index 00000000..2942728e --- /dev/null +++ b/web/src/lib/api/client/app/integrations/connectIntegration.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/disconnectIntegration.ts b/web/src/lib/api/client/app/integrations/disconnectIntegration.ts new file mode 100644 index 00000000..615cae81 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/disconnectIntegration.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listCatalog.ts b/web/src/lib/api/client/app/integrations/listCatalog.ts new file mode 100644 index 00000000..4ff4e20d --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listCatalog.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listConnections.ts b/web/src/lib/api/client/app/integrations/listConnections.ts new file mode 100644 index 00000000..b53fb4a1 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listConnections.ts @@ -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, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listMeetingBookings.ts b/web/src/lib/api/client/app/integrations/listMeetingBookings.ts new file mode 100644 index 00000000..ad2e5719 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listMeetingBookings.ts @@ -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, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts b/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts new file mode 100644 index 00000000..eb5d12fd --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts @@ -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"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts b/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts new file mode 100644 index 00000000..f119085a --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts @@ -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"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts b/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts new file mode 100644 index 00000000..22e1d57d --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts @@ -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, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts b/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts new file mode 100644 index 00000000..fc83db67 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts @@ -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, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts b/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts new file mode 100644 index 00000000..2767e7c4 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts @@ -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, + }); +} diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts new file mode 100644 index 00000000..758c2184 --- /dev/null +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 90eeb384..170fbfe7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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 />,