diff --git a/cmd/backend/main.go b/cmd/backend/main.go index ba1a0ad9..906f98cd 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -472,7 +472,8 @@ func main() { webhookServiceForHandler = webhookService integrationRepository := repository.NewIntegrationRepository(primaryDB.Pool) - integrationServiceForHandler = integration.NewService(integrationRepository) + // integrationServiceForHandler is constructed after cipherService below — + // OAuth/secret sealing depends on the envelope-encryption service. contactRepoForHandler = contactRepostory // Drain the webhook delivery queue in-process. Multiple replicas are @@ -526,6 +527,13 @@ func main() { ) cipherService = cipher.NewService(kms, cache, encryptedKeys) + // Third-party integrations: OAuth connect flows + encrypted token + // storage (sealed with the connecting user's DEK) + event-driven actions. + integrationServiceForHandler = integration.NewService(integrationRepository, cipherService, integration.NewOAuthManager()) + // Fan platform events (replies, bounces, warmup health, booked meetings) + // out to integration actions alongside customer webhooks. + webhookService.WireDispatchSink(integrationServiceForHandler.DispatchAny) + // Reflect the active infrastructure backends into storage_backends so // the admin UI can display what's running. Read-only entries — they // were chosen via env vars and changing them at runtime would orphan @@ -702,6 +710,9 @@ func main() { tasksClient, warmupService, ) + // Fan reply + bounce events from the advanced-outreach brain out to + // customer webhooks AND third-party integration actions (Slack / CRM). + advancedService.WireDispatcher(webhookService) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) tasksService = tasks.NewService( tasksClient, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index c607cfba..a97b0876 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -13,7 +13,9 @@ import ( "github.com/warmbly/warmbly/internal/app/advanced" "github.com/warmbly/warmbly/internal/app/cipher" jobs "github.com/warmbly/warmbly/internal/app/consumer" + "github.com/warmbly/warmbly/internal/app/integration" warmupapp "github.com/warmbly/warmbly/internal/app/warmup" + "github.com/warmbly/warmbly/internal/app/webhook" workerapp "github.com/warmbly/warmbly/internal/app/worker" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/events" @@ -185,6 +187,19 @@ func main() { crmRepo := repository.NewCRMRepository(primaryDB.Pool) advancedRepo := repository.NewAdvancedOutreachRepository(primaryDB.Pool) + // Reply → integration fan-out. The consumer is where inbound replies are + // detected, so this is where "prospect replied" turns into a Slack ping / + // CRM upsert. webhookService.Dispatch enqueues customer webhook deliveries + // (drained by the backend's DeliveryWorker) AND, via the wired sink, runs + // integration actions in-process (cipher + Postgres are available here; the + // consumer is control-plane, not a worker). Suppression already lives in the + // advanced repo, so no separate suppression repo is wired here. + webhookRepoC := repository.NewWebhookRepository(primaryDB.Pool) + webhookService := webhook.NewService(webhookRepoC) + integrationRepoC := repository.NewIntegrationRepository(primaryDB.Pool) + integrationServiceC := integration.NewService(integrationRepoC, cipherService, integration.NewOAuthManager()) + webhookService.WireDispatchSink(integrationServiceC.DispatchAny) + advancedService := advanced.NewService( advancedRepo, campaignRepo, @@ -193,9 +208,10 @@ func main() { contactRepo, campaignProgressRepo, crmRepo, - nil, + nil, // tasksClient: the consumer does not schedule Cloud Tasks warmupService, ) + advancedService.WireDispatcher(webhookService) // Events publisher — wraps the existing Kafka producer in an EventBus, // wraps Avrov2 in a Codec. Once EVENTBUS_PROVIDER=nats is exercised in diff --git a/docs/integrations-oauth-setup.md b/docs/integrations-oauth-setup.md new file mode 100644 index 00000000..fccda6cf --- /dev/null +++ b/docs/integrations-oauth-setup.md @@ -0,0 +1,149 @@ +# Integrations — OAuth provider setup + +Warmbly's integrations connect via **real OAuth 2.0** wherever the provider +supports it (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce). The connect +flow, CSRF/PKCE handling, token exchange, encrypted-at-rest token storage, and +automatic refresh are all built in. The only thing the platform operator must +supply is **one developer app per provider**, registered in that provider's +console, plus its client ID/secret as environment variables. + +Until a provider's credentials are present, it renders in the dashboard catalog +as **"Coming soon"** (the Connect button is disabled) — no code change is needed +to light it up; just add the env vars and restart the backend. + +## How it works (no code changes required to enable a provider) + +1. User clicks **Connect with X** in the dashboard. +2. SPA calls `POST /integrations/oauth/start` → backend mints a CSRF `state` + (+ PKCE verifier where supported), stores it in `integration_oauth_states`, + and returns the provider authorization URL. +3. SPA opens that URL in a popup. The user authorizes in the provider's UI. +4. The provider redirects to `GET /integrations/oauth/callback` (a public + bouncer page) which `postMessage`s `{code, state}` back to the SPA opener. +5. SPA calls `POST /integrations/oauth/finish` → backend validates+consumes the + `state`, exchanges the code for tokens, resolves the connected account, and + stores the access/refresh tokens **sealed with the connecting user's + envelope-encryption DEK** (KMS → per-user DEK → AES-GCM, the same path used + for mailbox OAuth tokens). Plaintext tokens never touch a database column. + +Access tokens are refreshed automatically (60s before expiry) using the stored +refresh token; if a refresh fails the connection flips to `reauth_required` and +the user is prompted to reconnect from the connection drawer. + +## Required environment variables + +Set these on the **backend** service. The redirect/callback URL must be allow- +listed in each provider's app config. + +``` +# Shared callback URL the providers redirect to. Defaults to +# $BACKEND_PUBLIC_URL/integrations/oauth/callback, else http://localhost:8080/... +INTEGRATIONS_OAUTH_REDIRECT_URL=https://api.yourdomain.com/integrations/oauth/callback + +# HubSpot — https://developers.hubspot.com/ (create a public app) +HUBSPOT_OAUTH_CLIENT_ID= +HUBSPOT_OAUTH_CLIENT_SECRET= + +# Slack — https://api.slack.com/apps (OAuth & Permissions → bot scopes: +# chat:write, channels:read, groups:read) +SLACK_OAUTH_CLIENT_ID= +SLACK_OAUTH_CLIENT_SECRET= + +# Google Sheets — https://console.cloud.google.com/ (OAuth client, enable the +# Google Sheets API; scopes: spreadsheets, userinfo.email) +GOOGLE_SHEETS_OAUTH_CLIENT_ID= +GOOGLE_SHEETS_OAUTH_CLIENT_SECRET= + +# Pipedrive — https://developers.pipedrive.com/ (Marketplace Manager app; +# scopes: contacts:full, deals:full) +PIPEDRIVE_OAUTH_CLIENT_ID= +PIPEDRIVE_OAUTH_CLIENT_SECRET= + +# Salesforce — https://developer.salesforce.com/ (Connected App; +# scopes: api, refresh_token) +SALESFORCE_OAUTH_CLIENT_ID= +SALESFORCE_OAUTH_CLIENT_SECRET= +``` + +The env var prefix is `_OAUTH_CLIENT_ID` / `_CLIENT_SECRET` — wiring a +new OAuth provider is just registering it in `internal/app/integration/oauth.go` +(`NewOAuthManager`) and adding the matching env vars. + +## Redirect URL to register with each provider + +``` + +# e.g. https://api.yourdomain.com/integrations/oauth/callback +``` + +For local development the default is `http://localhost:8080/integrations/oauth/callback`. + +## Providers that do NOT use OAuth + +- **Close** — no public OAuth app; the user pastes a Close API key. +- **Zapier / Make / n8n** — authenticate *into Warmbly* using a scoped Warmbly + API key the user generates under Settings → API keys. +- **Discord** — the user pastes a channel webhook URL (outbound only). +- **Calendly / Cal.com** — inbound only; Warmbly mints a signed inbound URL the + user pastes into the provider's webhook config. + +All pasted secrets are sealed with the same envelope encryption as OAuth tokens +before they are stored — nothing sensitive is ever persisted in plaintext. + +## How a user actually uses an integration + +Connecting is step one; the value is the **automations** a user builds on a +connection. After connecting, the user opens the connection's management drawer +(click any connected card on the Integrations page) and adds rules: + +> **When** a prospect replies — **only** positive replies, **≥60%** confidence — +> **notify** `#sales` with “🔥 {{contact_email}} is interested — {{subject}}”. + +Each rule is fully customizable in the UI (no code, no API keys to paste): + +- **Trigger** — which Warmbly event fires the rule (reply, bounce, unsubscribe, + warmup-health, complaint, meeting booked). +- **Filters** (reply triggers) — restrict to specific reply intents + (`positive`, `question`, `negative`, …) and a minimum classifier confidence. +- **Destination** — Slack channel, Google Sheet ID, or an outbound URL, + depending on the provider. +- **Message template** — a custom string with `{{placeholder}}` substitution + over the event payload (`{{contact_email}}`, `{{subject}}`, `{{intent}}`, + `{{campaign_id}}`, `{{reason}}`, …). + +Rules are stored in `integration_event_subscriptions.config` (JSONB) and applied +at dispatch time by `internal/app/integration/dispatch.go` +(`subscriptionMatchesFilter`, `renderTemplate`). + +## Event-driven actions — the wiring + +Examples of trigger → action pairs: + +- `campaign.reply_received` → `slack.notify` (ping #sales) +- `campaign.reply_received` → `hubspot.upsert_contact` (create/update + log note) +- `campaign.email_bounced` → `discord.notify` +- meeting booked (Calendly/Cal.com) → Slack / CRM + +Events reach integration actions through the webhook dispatch sink +(`webhook.Service.WireDispatchSink`), so any event already delivered to customer +webhooks also drives integration actions. The sink is wired in **both** binaries: + +- **`cmd/backend`** — deliverability ingest (bounce/complaint/unsubscribe), + meeting-booked, email-account lifecycle, warmup health. +- **`cmd/consumer`** — inbound campaign replies. The consumer is where replies + are detected (`advanced.ProcessIncomingReply`), which now emits + `campaign.reply_received` with `intent`/`confidence` in the payload. This is + what makes "notify me when a prospect replies → Slack/CRM" fire for real. + +`advanced.Service` exposes `WireDispatcher(EventDispatcher)` (see +`internal/app/advanced/events.go`); `ProcessIncomingReply` and +`IngestDeliverabilityEvent` call `emit(...)` to fan their events out. + +Each executed action is recorded as an `integration_sync_runs` row for +observability and surfaced in the connection drawer's "Recent activity". + +## Access / gating + +Integrations are a **paid-plan** feature (enforced in the integration handlers +via `FeatureGateService.IsPaidOrganization`). Browsing the catalog is open so +non-paid orgs see what's available; connecting requires an active paid plan. diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go index b45629a3..ee8ba8cc 100644 --- a/internal/api/handler/integration.go +++ b/internal/api/handler/integration.go @@ -2,6 +2,8 @@ package handler import ( "context" + "encoding/json" + "errors" "io" "net/http" "strings" @@ -9,21 +11,47 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" "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(), - }) +// requireIntegrationActor resolves the org + user for a mutating integration +// request and enforces the paid-plan gate. Browsing the catalog / listing +// connections is open (so non-paid orgs see the upsell); connecting or +// authorizing requires an active paid subscription. +func (h *Handler) requireIntegrationActor(c *gin.Context, requirePaid bool) (orgID, userID uuid.UUID, ok bool) { + orgID, ok = requireOrgID(c) + if !ok { + return uuid.Nil, uuid.Nil, false + } + uid, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return uuid.Nil, uuid.Nil, false + } + if requirePaid && h.FeatureGateService != nil { + paid, xerr := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), orgID) + if xerr != nil { + errx.JSON(c, xerr) + return uuid.Nil, uuid.Nil, false + } + if !paid { + errx.JSON(c, errx.New(errx.Forbidden, "Integrations are available on paid plans. Upgrade to connect this app.")) + return uuid.Nil, uuid.Nil, false + } + } + return orgID, uid, true } -// ListIntegrationConnections returns this org's connection rows. -// No secrets, no encrypted config. +// ListIntegrationCatalog returns the static metadata for every integration +// Warmbly supports, annotated with whether each OAuth provider is wired. +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). func (h *Handler) ListIntegrationConnections(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { @@ -31,76 +59,307 @@ func (h *Handler) ListIntegrationConnections(c *gin.Context) { } conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"}) + errx.JSON(c, errx.New(errx.Internal, "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) { +// GetIntegrationConnection returns a single connection with its event +// subscriptions and recent sync runs — the detail drawer payload. +func (h *Handler) GetIntegrationConnection(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"}) + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + conn, err := h.IntegrationService.GetConnection(c.Request.Context(), orgID, id) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to load connection")) + return + } + if conn == nil { + errx.JSON(c, errx.New(errx.NotFound, "connection not found")) + return + } + subs, _ := h.IntegrationService.ListEventSubscriptions(c.Request.Context(), orgID, id) + runs, _ := h.IntegrationService.ListSyncRuns(c.Request.Context(), orgID, id, 20) + if subs == nil { + subs = []models.IntegrationEventSubscription{} + } + if runs == nil { + runs = []models.IntegrationSyncRun{} + } + c.JSON(http.StatusOK, gin.H{"connection": conn, "events": subs, "runs": runs}) +} + +type integrationConnectPayload struct { + Provider string `json:"provider"` + Label string `json:"label"` + Config map[string]any `json:"config"` +} + +// ConnectIntegration creates a credential-based connection (api-key / webhook +// providers). OAuth providers are rejected here with a hint to use the +// authorize flow. +func (h *Handler) ConnectIntegration(c *gin.Context) { + orgID, userID, ok := h.requireIntegrationActor(c, true) + if !ok { + return + } + var p integrationConnectPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + provider := models.IntegrationProvider(strings.TrimSpace(p.Provider)) + if !models.IsValidIntegrationProvider(string(provider)) { + errx.JSON(c, errx.New(errx.BadRequest, "unknown provider")) + return + } + conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, userID, provider, p.Label, p.Config) + if err != nil { + if errors.Is(err, integration.ErrUseOAuth) { + errx.JSON(c, errx.New(errx.BadRequest, "This provider connects via OAuth — start the authorize flow instead.")) + return + } + errx.JSON(c, errx.New(errx.BadRequest, err.Error())) + return + } + h.auditIntegration(c, userID, models.AuditActionCreate, conn.ID, string(provider)) + c.JSON(http.StatusCreated, conn) +} + +// DisconnectIntegration removes a connection row. +func (h *Handler) DisconnectIntegration(c *gin.Context) { + orgID, userID, ok := h.requireIntegrationActor(c, false) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) return } if err := h.IntegrationService.Disconnect(c.Request.Context(), orgID, id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + errx.JSON(c, errx.New(errx.Internal, "delete failed")) + return + } + h.auditIntegration(c, userID, models.AuditActionDelete, id, "") + c.Status(http.StatusNoContent) +} + +type oauthStartPayload struct { + Provider string `json:"provider"` + Label string `json:"label"` +} + +// StartIntegrationOAuth returns the provider authorization URL for the SPA to +// open in a popup. JWT-only: it writes user-encrypted tokens on completion. +func (h *Handler) StartIntegrationOAuth(c *gin.Context) { + orgID, userID, ok := h.requireIntegrationActor(c, true) + if !ok { + return + } + var p oauthStartPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + provider := models.IntegrationProvider(strings.TrimSpace(p.Provider)) + if !models.IsValidIntegrationProvider(string(provider)) { + errx.JSON(c, errx.New(errx.BadRequest, "unknown provider")) + return + } + resp, err := h.IntegrationService.OAuthStart(c.Request.Context(), orgID, userID, provider, p.Label) + if err != nil { + if errors.Is(err, integration.ErrOAuthNotConfigured) { + errx.JSON(c, errx.New(errx.NotImplemented, "This provider isn't available yet — OAuth credentials are not configured on the server.")) + return + } + errx.JSON(c, errx.New(errx.BadRequest, err.Error())) + return + } + c.JSON(http.StatusOK, resp) +} + +type oauthFinishPayload struct { + Code string `json:"code"` + State string `json:"state"` +} + +// FinishIntegrationOAuth completes the handshake and persists the connection. +func (h *Handler) FinishIntegrationOAuth(c *gin.Context) { + userID, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + var p oauthFinishPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + conn, xerr := h.IntegrationService.OAuthFinish(c.Request.Context(), userID, p.Code, p.State) + if xerr != nil { + errx.JSON(c, errx.New(errx.BadRequest, xerr.Error())) + return + } + h.auditIntegration(c, userID, models.AuditActionCreate, conn.ID, string(conn.Provider)) + c.JSON(http.StatusCreated, conn) +} + +// ReauthIntegration starts a fresh OAuth handshake for an existing connection. +func (h *Handler) ReauthIntegration(c *gin.Context) { + orgID, userID, ok := h.requireIntegrationActor(c, true) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + resp, rerr := h.IntegrationService.Reauth(c.Request.Context(), orgID, userID, id) + if rerr != nil { + errx.JSON(c, errx.New(errx.BadRequest, rerr.Error())) + return + } + c.JSON(http.StatusOK, resp) +} + +// IntegrationOAuthCallback is the public bouncer page the provider redirects to. +// It postMessages the code+state back to the SPA opener, which then calls +// FinishIntegrationOAuth. Mirrors the mailbox onboarding callback. +func (h *Handler) IntegrationOAuthCallback(c *gin.Context) { + payload := map[string]string{ + "source": "warmbly-integration-oauth", + "code": c.Query("code"), + "state": c.Query("state"), + "error": c.Query("error"), + } + // json.Marshal escapes <, >, & so the blob is safe to inline in +` + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, html) +} + +// --- Event subscriptions ---------------------------------------------------- + +type eventSubscriptionPayload struct { + EventType string `json:"event_type"` + Action string `json:"action"` + Config map[string]any `json:"config"` + Enabled *bool `json:"enabled"` +} + +func (h *Handler) ListConnectionEventSubscriptions(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + connID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + subs, err := h.IntegrationService.ListEventSubscriptions(c.Request.Context(), orgID, connID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to list event subscriptions")) + return + } + if subs == nil { + subs = []models.IntegrationEventSubscription{} + } + c.JSON(http.StatusOK, gin.H{"events": subs}) +} + +func (h *Handler) CreateConnectionEventSubscription(c *gin.Context) { + orgID, userID, ok := h.requireIntegrationActor(c, true) + if !ok { + return + } + connID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var p eventSubscriptionPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + enabled := true + if p.Enabled != nil { + enabled = *p.Enabled + } + sub, err := h.IntegrationService.CreateEventSubscription(c.Request.Context(), orgID, connID, + strings.TrimSpace(p.EventType), models.IntegrationAction(strings.TrimSpace(p.Action)), p.Config, enabled) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, err.Error())) + return + } + h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "event:"+p.EventType) + c.JSON(http.StatusCreated, sub) +} + +func (h *Handler) DeleteConnectionEventSubscription(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + subID, err := uuid.Parse(c.Param("eventId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if err := h.IntegrationService.DeleteEventSubscription(c.Request.Context(), orgID, subID); err != nil { + errx.JSON(c, errx.New(errx.Internal, "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. +func (h *Handler) ListConnectionSyncRuns(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + connID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + runs, err := h.IntegrationService.ListSyncRuns(c.Request.Context(), orgID, connID, 50) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to list runs")) + return + } + if runs == nil { + runs = []models.IntegrationSyncRun{} + } + c.JSON(http.StatusOK, gin.H{"runs": runs}) +} + +// --- Inbound webhooks (Calendly / Cal.com) ---------------------------------- -// 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) } @@ -109,9 +368,6 @@ 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 == "" { @@ -150,14 +406,13 @@ func (h *Handler) handleInboundBooking(c *gin.Context, provider models.Integrati 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{ + if booking != nil { + data := map[string]any{ "source": booking.Source, "invitee_email": booking.InviteeEmail, "event_name": booking.EventName, @@ -165,7 +420,12 @@ func (h *Handler) handleInboundBooking(c *gin.Context, provider models.Integrati "contact_id": booking.ContactID, "booking_id": booking.ID, "trigger": "meeting_booked", - }) + } + // WebhookService.Dispatch fans the booking out to customer webhooks AND, + // via the wired sink, to integration event actions (Slack ping, CRM upsert). + if h.WebhookService != nil { + _, _ = h.WebhookService.Dispatch(c.Request.Context(), conn.OrganizationID, models.WebhookEventCampaignReplyReceived, data) + } } c.JSON(http.StatusOK, gin.H{"received": true}) } @@ -178,8 +438,21 @@ func (h *Handler) ListMeetingBookings(c *gin.Context) { } rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + errx.JSON(c, errx.New(errx.Internal, "list failed")) return } c.JSON(http.StatusOK, gin.H{"bookings": rows}) } + +// auditIntegration is a thin best-effort audit-log wrapper. +func (h *Handler) auditIntegration(c *gin.Context, userID uuid.UUID, action models.AuditAction, entityID uuid.UUID, detail string) { + if h.AuditService == nil { + return + } + meta := map[string]string{} + if detail != "" { + meta["detail"] = detail + } + id := entityID + h.AuditService.LogAction(c.Request.Context(), userID, action, models.AuditEntityIntegration, &id, c.ClientIP(), c.Request.UserAgent(), meta, nil) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 396d5979..b8c152df 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -50,6 +50,11 @@ func Run( r.GET("/addresses/google/callback", h.EmailOAuthCallbackGmail) r.GET("/addresses/outlook/callback", h.EmailOAuthCallbackOutlook) + // Public OAuth callback bouncer for third-party integrations (HubSpot, + // Slack, Google, Pipedrive, …). The provider redirects here; the page + // postMessages code+state to the SPA opener, which calls oauth/finish. + r.GET("/integrations/oauth/callback", h.IntegrationOAuthCallback) + // Internal backend-to-backend endpoints. Workers call these instead of // touching Postgres / DynamoDB directly, per the no-direct-data-services // rule in CLAUDE.md. Auth: shared bearer token (INTERNAL_API_TOKEN). @@ -171,6 +176,16 @@ func Run( onboardingEmails.POST("/smtp-imap", h.ConnectEmailSMTPIMAP) } + // Integration OAuth handshake is JWT-only — it writes user-encrypted + // provider tokens via the SPA popup flow, same as mailbox onboarding. + integrationsOAuth := jwtOnly.Group("/integrations/oauth") + integrationsOAuth.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + integrationsOAuth.POST("/start", h.StartIntegrationOAuth) + integrationsOAuth.POST("/finish", h.FinishIntegrationOAuth) + integrationsOAuth.POST("/reauth/:id", h.ReauthIntegration) + } + campaigns := protected.Group("/campaigns") campaigns.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { @@ -365,7 +380,12 @@ func Run( integrations.GET("/catalog", h.ListIntegrationCatalog) integrations.GET("/connections", h.ListIntegrationConnections) integrations.POST("/connections", h.ConnectIntegration) + integrations.GET("/connections/:id", h.GetIntegrationConnection) integrations.DELETE("/connections/:id", h.DisconnectIntegration) + integrations.GET("/connections/:id/events", h.ListConnectionEventSubscriptions) + integrations.POST("/connections/:id/events", h.CreateConnectionEventSubscription) + integrations.DELETE("/connections/:id/events/:eventId", h.DeleteConnectionEventSubscription) + integrations.GET("/connections/:id/runs", h.ListConnectionSyncRuns) integrations.GET("/bookings", h.ListMeetingBookings) } diff --git a/internal/app/advanced/events.go b/internal/app/advanced/events.go new file mode 100644 index 00000000..0961cf8d --- /dev/null +++ b/internal/app/advanced/events.go @@ -0,0 +1,36 @@ +package advanced + +import ( + "context" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// EventDispatcher fans a platform event out to customer webhooks and, via the +// webhook dispatch sink, to third-party integration actions (Slack ping, CRM +// upsert). It is satisfied by *webhook.Service. Kept as a local interface so +// the advanced package stays decoupled from the webhook package (no import +// cycle, and the consumer/backend wire whichever dispatcher they construct). +type EventDispatcher interface { + Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) +} + +// WireDispatcher attaches the event dispatcher after construction. Done this +// way (rather than via the constructor) so the dispatcher — which itself may +// depend on services constructed later — can be supplied once the graph is +// fully wired. No-op if never called: emit() guards on a nil dispatcher. +func (s *service) WireDispatcher(d EventDispatcher) { + s.dispatcher = d +} + +// emit dispatches a platform event, best-effort. Reply detection runs in the +// consumer's hot path, so a webhook/integration hiccup must never block inbox +// ingest — failures are swallowed (Dispatch already logs its own). +func (s *service) emit(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) { + if s.dispatcher == nil || orgID == uuid.Nil { + return + } + _, _ = s.dispatcher.Dispatch(ctx, orgID, eventType, data) +} diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index d9b23756..49df8d1a 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -48,6 +48,11 @@ type Service interface { ProcessIncomingReply(ctx context.Context, emailAccountID uuid.UUID, msg *models.EmailMessageStoreData) *errx.Error GetABWinnerAnalysis(ctx context.Context, organizationID, campaignID uuid.UUID) (*models.ABWinnerAnalysis, *errx.Error) + // WireDispatcher attaches the event dispatcher that fans classified + // replies + deliverability events out to customer webhooks and third-party + // integration actions (Slack ping, CRM upsert). + WireDispatcher(d EventDispatcher) + // DLQ auto-retry ProcessRetryableDeadLetters(ctx context.Context) (int, *errx.Error) } @@ -62,6 +67,7 @@ type service struct { crmRepo repository.CRMRepository tasksClient *gtasks.Client warmupService warmupapp.Service + dispatcher EventDispatcher } func NewService( @@ -558,6 +564,34 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid. }, }) + // Fan the classified reply out to customer webhooks + integration actions + // (Slack ping, CRM upsert). The intent/confidence fields let an integration + // automation filter for e.g. only "positive" replies. This is the trigger + // behind "notify me when a prospect replies". + payload := map[string]any{ + "contact_email": sender, + "intent": string(intent), + "confidence": confidence, + "subject": msg.Subject, + "snippet": msg.Snippet, + "action_taken": actionTaken, + "trigger": "campaign_reply", + } + if campaignID != nil { + payload["campaign_id"] = campaignID.String() + } + if contactID != nil { + payload["contact_id"] = contactID.String() + } + s.emit(ctx, *account.OrganizationID, models.WebhookEventCampaignReplyReceived, payload) + if intent == models.ReplyIntentNegative { + // Negative/unsubscribe-leaning replies also fire the unsubscribe event + // only when we actually suppressed; otherwise the reply event is enough. + if strings.Contains(actionTaken, "suppressed_recipient") { + s.emit(ctx, *account.OrganizationID, models.WebhookEventCampaignUnsubscribed, payload) + } + } + return nil } @@ -664,6 +698,31 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID } } + // Fan bounce / complaint / unsubscribe out to customer webhooks + + // integration actions so a Slack channel or CRM can react in real time. + payload := map[string]any{ + "contact_email": req.RecipientEmail, + "recipient": req.RecipientEmail, + "event_type": string(eventType), + "provider": provider, + "reason": req.Reason, + } + if req.CampaignID != nil { + payload["campaign_id"] = req.CampaignID.String() + } + if req.ContactID != nil { + payload["contact_id"] = req.ContactID.String() + } + switch eventType { + case models.DeliverabilityEventBounce: + s.emit(ctx, organizationID, models.WebhookEventCampaignEmailBounced, payload) + s.emit(ctx, organizationID, models.WebhookEventDeliverabilityBounce, payload) + case models.DeliverabilityEventComplaint: + s.emit(ctx, organizationID, models.WebhookEventDeliverabilityComplaint, payload) + case models.DeliverabilityEventUnsubscribe: + s.emit(ctx, organizationID, models.WebhookEventCampaignUnsubscribed, payload) + } + return nil } diff --git a/internal/app/integration/actions.go b/internal/app/integration/actions.go new file mode 100644 index 00000000..448a96ea --- /dev/null +++ b/internal/app/integration/actions.go @@ -0,0 +1,261 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// actionHTTP is the shared client for outbound provider action calls. Short +// timeout — a slow third party shouldn't pin a dispatch goroutine for long. +var actionHTTP = &http.Client{Timeout: 15 * time.Second} + +// slackPostMessage posts to a channel using a bot token from the OAuth connect. +// Slack returns HTTP 200 with {ok:false,error:...} on failure, so we inspect +// the body rather than the status code. +func slackPostMessage(ctx context.Context, token, channel string, msg eventMessage) error { + if channel == "" { + return fmt.Errorf("no slack channel configured") + } + body, _ := json.Marshal(map[string]any{ + "channel": channel, + "text": msg.plainText(), + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := actionHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = json.Unmarshal(raw, &out) + if !out.OK { + if out.Error == "" { + out.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return fmt.Errorf("slack chat.postMessage: %s", out.Error) + } + return nil +} + +// webhookPost delivers a Discord-compatible payload (and works for any generic +// JSON webhook): Discord requires a top-level "content" string. +func webhookPost(ctx context.Context, url string, msg eventMessage) error { + body, _ := json.Marshal(map[string]any{ + "content": msg.plainText(), + "email": msg.Email, + "subject": msg.Subject, + "title": msg.Title, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := actionHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook POST: HTTP %d", resp.StatusCode) + } + return nil +} + +// hubspotUpsertContact creates or updates a HubSpot contact keyed by email and +// logs the event as a note on the contact timeline (best-effort). +func hubspotUpsertContact(ctx context.Context, token string, data map[string]any, msg eventMessage) error { + email := msg.Email + if email == "" { + // Nothing to key on; treat as a no-op success. + return nil + } + firstName := stringFromMap(data, "first_name", "contact_first_name") + lastName := stringFromMap(data, "last_name", "contact_last_name") + + props := map[string]any{"email": email} + if firstName != "" { + props["firstname"] = firstName + } + if lastName != "" { + props["lastname"] = lastName + } + + // Find existing contact by email. + searchBody, _ := json.Marshal(map[string]any{ + "filterGroups": []map[string]any{{ + "filters": []map[string]any{{ + "propertyName": "email", "operator": "EQ", "value": email, + }}, + }}, + "properties": []string{"email"}, + "limit": 1, + }) + var search struct { + Results []struct { + ID string `json:"id"` + } `json:"results"` + } + if err := hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/contacts/search", token, searchBody, &search); err != nil { + return err + } + + contactID := "" + if len(search.Results) > 0 { + contactID = search.Results[0].ID + upd, _ := json.Marshal(map[string]any{"properties": props}) + if err := hubspotJSON(ctx, http.MethodPatch, "https://api.hubapi.com/crm/v3/objects/contacts/"+contactID, token, upd, nil); err != nil { + return err + } + } else { + create, _ := json.Marshal(map[string]any{"properties": props}) + var created struct { + ID string `json:"id"` + } + if err := hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/contacts", token, create, &created); err != nil { + return err + } + contactID = created.ID + } + + // Best-effort note on the timeline. + if contactID != "" { + note, _ := json.Marshal(map[string]any{ + "properties": map[string]any{ + "hs_note_body": msg.plainText(), + "hs_timestamp": time.Now().UTC().Format(time.RFC3339), + }, + "associations": []map[string]any{{ + "to": map[string]any{"id": contactID}, + "types": []map[string]any{{ + "associationCategory": "HUBSPOT_DEFINED", + "associationTypeId": 202, + }}, + }}, + }) + _ = hubspotJSON(ctx, http.MethodPost, "https://api.hubapi.com/crm/v3/objects/notes", token, note, nil) + } + return nil +} + +func hubspotJSON(ctx context.Context, method, url, token string, body []byte, dst any) error { + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := actionHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("hubspot %s %s: HTTP %d", method, shortURL(url), resp.StatusCode) + } + if dst != nil && len(raw) > 0 { + return json.Unmarshal(raw, dst) + } + return nil +} + +// pipedriveUpsertPerson creates a Pipedrive person keyed by email. Pipedrive's +// REST API does not offer a true upsert, so we search first. +func pipedriveUpsertPerson(ctx context.Context, token string, data map[string]any) error { + email := stringFromMap(data, "contact_email", "invitee_email", "email") + if email == "" { + return nil + } + name := stringFromMap(data, "contact_name", "invitee_name", "name") + if name == "" { + name = email + } + + // Search for an existing person by email. + searchURL := "https://api.pipedrive.com/v1/persons/search?term=" + url.QueryEscape(email) + "&fields=email&exact_match=true" + var search struct { + Data struct { + Items []struct { + Item struct { + ID int64 `json:"id"` + } `json:"item"` + } `json:"items"` + } `json:"data"` + } + if err := pipedriveJSON(ctx, http.MethodGet, searchURL, token, nil, &search); err != nil { + return err + } + if len(search.Data.Items) > 0 { + return nil // already present; nothing to do + } + + create, _ := json.Marshal(map[string]any{ + "name": name, + "email": []string{email}, + }) + return pipedriveJSON(ctx, http.MethodPost, "https://api.pipedrive.com/v1/persons", token, create, nil) +} + +func pipedriveJSON(ctx context.Context, method, url, token string, body []byte, dst any) error { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := actionHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("pipedrive %s: HTTP %d", method, resp.StatusCode) + } + if dst != nil && len(raw) > 0 { + return json.Unmarshal(raw, dst) + } + return nil +} + +// sheetsAppendRow appends an event row to a connected Google Sheet using the +// existing Sheets client wrapper. +func sheetsAppendRow(ctx context.Context, token, sheetID string, data map[string]any) error { + client := NewSheetsClient(token) + row := []string{ + time.Now().UTC().Format(time.RFC3339), + stringFromMap(data, "contact_email", "invitee_email", "email"), + stringFromMap(data, "subject", "event_name", "campaign_name"), + } + return client.AppendValues(ctx, sheetID, "A1", [][]string{row}) +} + +func shortURL(u string) string { + if i := len("https://api.hubapi.com"); len(u) > i { + return u[i:] + } + return u +} diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go index a966735d..a0a12481 100644 --- a/internal/app/integration/catalog.go +++ b/internal/app/integration/catalog.go @@ -1,127 +1,170 @@ // Package integration owns the third-party integrations surface: catalog -// metadata, per-provider connect/disconnect, and inbound webhook handling -// for Calendly + Cal.com. +// metadata, OAuth connect flows, per-provider connect/disconnect, event-driven +// actions, 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. +// Per-provider files (oauth.go, slack.go, hubspot.go, discord.go, +// google_sheets.go, calendly.go) each handle the provider-specific request / +// response shapes. 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" +// Reply / bounce / meeting are the most actionable events for outbound teams, +// so they're offered as triggers on the relevant providers. +var ( + crmEvents = []string{ + string(models.WebhookEventCampaignReplyReceived), + string(models.WebhookEventCampaignEmailBounced), + string(models.WebhookEventCampaignUnsubscribed), + } + notifyEvents = []string{ + string(models.WebhookEventCampaignReplyReceived), + string(models.WebhookEventCampaignEmailBounced), + string(models.WebhookEventWarmupHealthChanged), + string(models.WebhookEventDeliverabilityComplaint), + } +) + // Catalog returns the static metadata for every integration the dashboard -// renders. Order is the catalog order users see. +// renders. Order is the catalog order users see. Per-connection Configured / +// Scopes are filled in by the service from the OAuth manager. func Catalog() []models.IntegrationCatalogEntry { return []models.IntegrationCatalogEntry{ - // CRM + // CRM --------------------------------------------------------------- { Provider: models.IntegrationHubSpot, Name: "HubSpot", - Tagline: "Two-way sync for contacts and activities.", + Tagline: "Push positive replies and new leads straight into your CRM.", Category: models.IntegrationCategoryCRM, - AuthMethod: "oauth", + AuthMethod: string(models.IntegrationAuthOAuth), DocsURL: "https://developers.hubspot.com/docs/api/overview", + Highlights: []string{ + "One-click OAuth — no API keys to copy", + "Create or update a HubSpot contact when a prospect replies", + "Log the reply as a note on the contact timeline", + }, + Events: crmEvents, }, { Provider: models.IntegrationSalesforce, Name: "Salesforce", - Tagline: "Sync leads, contacts, and email activity.", + Tagline: "Sync leads, contacts, and email activity to Salesforce.", Category: models.IntegrationCategoryCRM, - AuthMethod: "oauth", + AuthMethod: string(models.IntegrationAuthOAuth), DocsURL: "https://developer.salesforce.com/docs", + Highlights: []string{"OAuth connect", "Lead + contact sync on reply"}, + Events: crmEvents, }, { Provider: models.IntegrationPipedrive, Name: "Pipedrive", - Tagline: "Persons, deals, and activity timeline.", + Tagline: "Persons, deals, and an activity timeline that stays in sync.", Category: models.IntegrationCategoryCRM, - AuthMethod: "oauth", + AuthMethod: string(models.IntegrationAuthOAuth), DocsURL: "https://developers.pipedrive.com", + Highlights: []string{"OAuth connect", "Upsert a person when a prospect replies"}, + Events: crmEvents, }, { Provider: models.IntegrationClose, Name: "Close", - Tagline: "Leads, contacts, and inbox activity.", + Tagline: "Leads, contacts, and inbox activity for Close.", Category: models.IntegrationCategoryCRM, - AuthMethod: "api_key", + AuthMethod: string(models.IntegrationAuthAPIKey), DocsURL: "https://developer.close.com", + Highlights: []string{"Paste your Close API key (no OAuth app available)"}, }, - // Automation + // Automation -------------------------------------------------------- { Provider: models.IntegrationZapier, Name: "Zapier", Tagline: "Triggers and actions across 8,000+ apps.", Category: models.IntegrationCategoryAutomation, - AuthMethod: "api_key", + AuthMethod: string(models.IntegrationAuthAPIKey), DocsURL: "https://zapier.com/apps", + Highlights: []string{"Authenticate Zapier with a scoped Warmbly API key"}, }, { Provider: models.IntegrationMake, Name: "Make", Tagline: "Visual automation scenarios.", Category: models.IntegrationCategoryAutomation, - AuthMethod: "api_key", + AuthMethod: string(models.IntegrationAuthAPIKey), DocsURL: "https://www.make.com/en/integrations", + Highlights: []string{"Authenticate Make with a scoped Warmbly API key"}, }, { Provider: models.IntegrationN8N, Name: "n8n", Tagline: "Self-hosted automation workflows.", Category: models.IntegrationCategoryAutomation, - AuthMethod: "api_key", + AuthMethod: string(models.IntegrationAuthAPIKey), DocsURL: "https://docs.n8n.io", + Highlights: []string{"Authenticate n8n with a scoped Warmbly API key"}, }, - // Notifications + // 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.IntegrationSlack, + Name: "Slack", + Tagline: "Real-time alerts for positive replies, bounces, and deliverability.", + Category: models.IntegrationCategoryNotifications, + AuthMethod: string(models.IntegrationAuthOAuth), + DocsURL: "https://api.slack.com", + Highlights: []string{ + "One-click OAuth into your workspace", + "Ping a channel the moment a prospect replies", + "Warn the team when warmup health or deliverability dips", + }, + Events: notifyEvents, }, { Provider: models.IntegrationDiscord, Name: "Discord", Tagline: "Webhook-based notifications to a server channel.", Category: models.IntegrationCategoryNotifications, - AuthMethod: "webhook", + AuthMethod: string(models.IntegrationAuthWebhook), DocsURL: "https://discord.com/developers/docs/resources/webhook", WebhookHint: "Paste a Discord channel webhook URL.", + Highlights: []string{"Paste a channel webhook URL", "Ping on reply / bounce / warmup health"}, + Events: notifyEvents, }, - // Meetings + // Meetings ---------------------------------------------------------- { Provider: models.IntegrationCalendly, Name: "Calendly", Tagline: "Attribute booked meetings to the campaign that surfaced the lead.", Category: models.IntegrationCategoryMeetings, - AuthMethod: "webhook", + AuthMethod: string(models.IntegrationAuthWebhook), DocsURL: "https://developer.calendly.com/api-docs/", - WebhookHint: "Calendly POSTs invitee.created here.", + WebhookHint: "Calendly POSTs invitee.created to the URL we mint.", + Highlights: []string{"We mint an inbound URL for you", "Booked meetings credit the originating campaign"}, }, { Provider: models.IntegrationCalCom, Name: "Cal.com", Tagline: "Same attribution path, open-source booking edition.", Category: models.IntegrationCategoryMeetings, - AuthMethod: "webhook", + AuthMethod: string(models.IntegrationAuthWebhook), DocsURL: "https://cal.com/docs/core-features/webhooks", - WebhookHint: "Cal.com POSTs BOOKING_CREATED events here.", + WebhookHint: "Cal.com POSTs BOOKING_CREATED to the URL we mint.", + Highlights: []string{"We mint an inbound URL for you", "Booked meetings credit the originating campaign"}, }, - // Data + // Data -------------------------------------------------------------- { Provider: models.IntegrationGoogleSheets, Name: "Google Sheets", - Tagline: "Pull leads from a sheet, push reply / bounce / booked events back.", + Tagline: "Pull leads from a sheet; push reply / bounce / booked rows back.", Category: models.IntegrationCategoryData, - AuthMethod: "oauth", + AuthMethod: string(models.IntegrationAuthOAuth), DocsURL: "https://developers.google.com/sheets/api", BetaFlag: true, + Highlights: []string{"One-click Google OAuth", "Append a row on reply / bounce"}, + Events: crmEvents, }, } } diff --git a/internal/app/integration/dispatch.go b/internal/app/integration/dispatch.go new file mode 100644 index 00000000..583ecbba --- /dev/null +++ b/internal/app/integration/dispatch.go @@ -0,0 +1,338 @@ +package integration + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// errReauthRequired marks an action failure caused by an unrecoverable token +// problem. accessTokenFor already flips the connection to reauth_required, so +// the dispatcher must not overwrite that status when it sees this error. +var errReauthRequired = errors.New("reauth required") + +// Dispatch fans a platform event out to every matching event subscription. +// Targets are resolved synchronously (cheap, indexed) but the provider calls +// run on a detached context so the caller (an API handler or consumer) never +// blocks on a third-party round trip. Best-effort by design: a failing action +// is recorded on the connection's health and in a sync run, never propagated. +func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) { + targets, err := s.repo.MatchingDispatchTargets(ctx, orgID, string(eventType)) + if err != nil { + log.Warn().Err(err).Str("event", string(eventType)).Msg("integration dispatch: failed to load targets") + return + } + if len(targets) == 0 { + return + } + go func(targets []repository.DispatchTarget) { + bg, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for i := range targets { + s.runAction(bg, targets[i], data) + } + }(targets) +} + +// DispatchAny forwards a loosely-typed event payload to Dispatch when it is a +// map. Wired into the webhook fan-out sink so any event delivered to customer +// webhooks also drives integration actions. +func (s *service) DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) { + if m, ok := data.(map[string]any); ok { + s.Dispatch(ctx, orgID, eventType, m) + } +} + +func (s *service) runAction(ctx context.Context, target repository.DispatchTarget, data map[string]any) { + sub := target.Subscription + // Per-subscription filter (e.g. only "positive" replies, or a minimum + // classifier confidence). Filtered-out events are a silent no-op — no sync + // run, no connection-health churn. + if !subscriptionMatchesFilter(sub, data) { + return + } + run := &models.IntegrationSyncRun{ + ConnectionID: sub.ConnectionID, + OrganizationID: sub.OrganizationID, + Kind: "event_dispatch", + Detail: fmt.Sprintf("%s on %s", sub.Action, sub.EventType), + } + _ = s.repo.CreateSyncRun(ctx, run) + + if err := s.execAction(ctx, target, data); err != nil { + _ = s.repo.FinishSyncRun(ctx, run.ID, "error", truncate(err.Error(), 480), 0) + // Don't clobber a reauth_required status the token path already set. + if !errors.Is(err, errReauthRequired) { + _ = s.repo.SetConnectionStatus(ctx, sub.ConnectionID, models.IntegrationStatusConnected, models.IntegrationHealthDegraded, truncate(err.Error(), 480)) + } + log.Warn().Err(err).Str("action", string(sub.Action)).Str("connection", sub.ConnectionID.String()).Msg("integration action failed") + return + } + _ = s.repo.FinishSyncRun(ctx, run.ID, "success", "", 1) + _ = s.repo.SetConnectionStatus(ctx, sub.ConnectionID, models.IntegrationStatusConnected, models.IntegrationHealthHealthy, "") +} + +// execAction routes a subscription to its provider implementation. +func (s *service) execAction(ctx context.Context, target repository.DispatchTarget, data map[string]any) error { + sub := target.Subscription + cfg, err := s.openConfig(ctx, &target.Secrets) + if err != nil { + return fmt.Errorf("decrypt config: %w", err) + } + msg := renderEventMessage(sub, data) + + switch sub.Action { + case models.IntegrationActionSlackNotify: + channel := configString(sub.Config, "channel") + token, terr := s.accessTokenFor(ctx, &target.Secrets) + if terr != nil { + return errReauthRequired + } + return slackPostMessage(ctx, token, channel, msg) + + case models.IntegrationActionDiscordNotify, models.IntegrationActionGenericWebhookPing: + url := stringFromMap(cfg, "webhook_url") + if url == "" { + url = configString(sub.Config, "url") + } + if url == "" { + return errors.New("no webhook url configured") + } + return webhookPost(ctx, url, msg) + + case models.IntegrationActionHubSpotUpsert: + token, terr := s.accessTokenFor(ctx, &target.Secrets) + if terr != nil { + return errReauthRequired + } + return hubspotUpsertContact(ctx, token, data, msg) + + case models.IntegrationActionPipedriveUpsert: + token, terr := s.accessTokenFor(ctx, &target.Secrets) + if terr != nil { + return errReauthRequired + } + return pipedriveUpsertPerson(ctx, token, data) + + case models.IntegrationActionSheetsAppend: + token, terr := s.accessTokenFor(ctx, &target.Secrets) + if terr != nil { + return errReauthRequired + } + sheetID := configString(sub.Config, "sheet_id") + if sheetID == "" { + return errors.New("no sheet_id configured") + } + return sheetsAppendRow(ctx, token, sheetID, data) + + default: + return fmt.Errorf("unknown action: %s", sub.Action) + } +} + +// subscriptionMatchesFilter applies the optional, user-defined filters stored +// in a subscription's config so automations are fully customizable: +// +// - intents: []string — only fire when data["intent"] is in this set +// (e.g. ["positive"] => "only notify me on positive replies"). +// - min_confidence: number (0..1) — only fire when the classifier +// confidence meets this floor. +// +// No filters configured => always matches. +func subscriptionMatchesFilter(sub models.IntegrationEventSubscription, data map[string]any) bool { + cfg := map[string]any{} + if len(sub.Config) > 0 { + _ = json.Unmarshal(sub.Config, &cfg) + } + + if raw, ok := cfg["intents"]; ok { + wanted := toStringSet(raw) + if len(wanted) > 0 { + got := strings.ToLower(stringFromMap(data, "intent")) + if got == "" || !wanted[got] { + return false + } + } + } + + if raw, ok := cfg["min_confidence"]; ok { + floor := toFloat(raw) + if floor > 0 { + if got, ok := data["confidence"]; ok { + if toFloat(got) < floor { + return false + } + } + } + } + + return true +} + +// eventMessage is the normalized, human-readable summary an action renders. +// Custom is the rendered user-supplied template; when set it overrides the +// auto-generated Title/Detail text in notifications. +type eventMessage struct { + Title string + Email string + Subject string + Detail string + Custom string +} + +func renderEventMessage(sub models.IntegrationEventSubscription, data map[string]any) eventMessage { + eventType := models.WebhookEventType(sub.EventType) + email := stringFromMap(data, "contact_email", "invitee_email", "email", "recipient") + subject := stringFromMap(data, "subject", "event_name", "campaign_name", "campaign") + m := eventMessage{Email: email, Subject: subject} + + switch eventType { + case models.WebhookEventCampaignReplyReceived: + m.Title = "📨 New reply" + if intent := stringFromMap(data, "intent"); intent != "" { + m.Title = "📨 New reply (" + intent + ")" + } + case models.WebhookEventCampaignEmailBounced, models.WebhookEventDeliverabilityBounce: + m.Title = "⚠️ Email bounced" + case models.WebhookEventCampaignUnsubscribed: + m.Title = "🚫 Unsubscribe" + case models.WebhookEventWarmupHealthChanged: + m.Title = "🌡️ Warmup health changed" + case models.WebhookEventDeliverabilityComplaint: + m.Title = "❗ Spam complaint" + default: + m.Title = "Warmbly event: " + string(eventType) + } + + var parts []string + if email != "" { + parts = append(parts, email) + } + if subject != "" { + parts = append(parts, subject) + } + m.Detail = strings.Join(parts, " · ") + + // Optional custom template: "{{contact_email}} replied to {{subject}}". + // Placeholders are any key present in the event payload. + if tmpl := configString(sub.Config, "message_template"); tmpl != "" { + m.Custom = renderTemplate(tmpl, data) + } + return m +} + +func (m eventMessage) plainText() string { + if m.Custom != "" { + return m.Custom + } + if m.Detail == "" { + return m.Title + } + return m.Title + " — " + m.Detail +} + +// renderTemplate substitutes {{key}} placeholders with values from the event +// payload. Unknown placeholders render as empty so a typo can't leak braces. +func renderTemplate(tmpl string, data map[string]any) string { + out := tmpl + for { + start := strings.Index(out, "{{") + if start < 0 { + break + } + end := strings.Index(out[start:], "}}") + if end < 0 { + break + } + end += start + key := strings.TrimSpace(out[start+2 : end]) + val := stringFromMap(data, key) + out = out[:start] + val + out[end+2:] + } + return strings.TrimSpace(out) +} + +func configString(raw json.RawMessage, key string) string { + if len(raw) == 0 { + return "" + } + m := map[string]any{} + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + return stringFromMap(m, key) +} + +func stringFromMap(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch t := v.(type) { + case string: + if strings.TrimSpace(t) != "" { + return strings.TrimSpace(t) + } + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + return strconv.FormatBool(t) + } + } + } + return "" +} + +func toStringSet(raw any) map[string]bool { + out := map[string]bool{} + switch t := raw.(type) { + case []any: + for _, v := range t { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out[strings.ToLower(strings.TrimSpace(s))] = true + } + } + case []string: + for _, s := range t { + if strings.TrimSpace(s) != "" { + out[strings.ToLower(strings.TrimSpace(s))] = true + } + } + case string: + for _, s := range strings.Split(t, ",") { + if strings.TrimSpace(s) != "" { + out[strings.ToLower(strings.TrimSpace(s))] = true + } + } + } + return out +} + +func toFloat(raw any) float64 { + switch t := raw.(type) { + case float64: + return t + case int: + return float64(t) + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil { + return f + } + } + return 0 +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/app/integration/oauth.go b/internal/app/integration/oauth.go new file mode 100644 index 00000000..bdf24b8f --- /dev/null +++ b/internal/app/integration/oauth.go @@ -0,0 +1,371 @@ +package integration + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/warmbly/warmbly/internal/models" +) + +// OAuthManager owns the OAuth 2.0 authorization-code machinery for every +// provider that supports it. Client credentials are read from the environment +// at construction (one app per provider, registered in that provider's +// developer console). A provider with no credentials configured is reported +// as not-Configured so the dashboard renders it as "coming soon" rather than a +// dead Connect button — the framework lights up the moment real credentials +// are supplied, no code change required. +// +// This mirrors the mailbox OAuth flow in internal/app/email/oauth.go: start → +// provider popup → callback page postMessages code+state → finish exchanges and +// persists encrypted tokens. +type OAuthManager struct { + redirectURL string + providers map[models.IntegrationProvider]*oauthProvider + http *http.Client +} + +// identifyFunc resolves the connected external account (id + display name) and +// the scopes actually granted, given a fresh token. +type identifyFunc func(ctx context.Context, m *OAuthManager, tok *oauth2.Token) (extID, extName string, scopes []string, err error) + +type oauthProvider struct { + provider models.IntegrationProvider + config *oauth2.Config + scopes []string + usePKCE bool + identify identifyFunc +} + +// NewOAuthManager builds the provider registry from environment variables. For +// each provider it reads _OAUTH_CLIENT_ID / _OAUTH_CLIENT_SECRET +// (e.g. HUBSPOT_OAUTH_CLIENT_ID). The shared redirect/callback URL comes from +// INTEGRATIONS_OAUTH_REDIRECT_URL, else BACKEND_PUBLIC_URL + the callback path, +// else a localhost default for dev. +func NewOAuthManager() *OAuthManager { + redirect := strings.TrimSpace(os.Getenv("INTEGRATIONS_OAUTH_REDIRECT_URL")) + if redirect == "" { + base := strings.TrimRight(strings.TrimSpace(os.Getenv("BACKEND_PUBLIC_URL")), "/") + if base == "" { + base = "http://localhost:8080" + } + redirect = base + "/integrations/oauth/callback" + } + + m := &OAuthManager{ + redirectURL: redirect, + providers: map[models.IntegrationProvider]*oauthProvider{}, + http: &http.Client{Timeout: 15 * time.Second}, + } + + register := func(p models.IntegrationProvider, envPrefix string, ep oauth2.Endpoint, scopes []string, usePKCE bool, id identifyFunc) { + clientID := strings.TrimSpace(os.Getenv(envPrefix + "_OAUTH_CLIENT_ID")) + clientSecret := strings.TrimSpace(os.Getenv(envPrefix + "_OAUTH_CLIENT_SECRET")) + op := &oauthProvider{provider: p, scopes: scopes, usePKCE: usePKCE, identify: id} + if clientID != "" && clientSecret != "" { + op.config = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: ep, + RedirectURL: redirect, + Scopes: scopes, + } + } + m.providers[p] = op + } + + register(models.IntegrationHubSpot, "HUBSPOT", oauth2.Endpoint{ + AuthURL: "https://app.hubspot.com/oauth/authorize", + TokenURL: "https://api.hubapi.com/oauth/v1/token", + }, []string{"oauth", "crm.objects.contacts.read", "crm.objects.contacts.write"}, false, identifyHubSpot) + + register(models.IntegrationSlack, "SLACK", oauth2.Endpoint{ + AuthURL: "https://slack.com/oauth/v2/authorize", + TokenURL: "https://slack.com/api/oauth.v2.access", + }, []string{"chat:write", "channels:read", "groups:read"}, false, identifySlack) + + register(models.IntegrationGoogleSheets, "GOOGLE_SHEETS", oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + }, []string{ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/userinfo.email", + }, true, identifyGoogle) + + register(models.IntegrationPipedrive, "PIPEDRIVE", oauth2.Endpoint{ + AuthURL: "https://oauth.pipedrive.com/oauth/authorize", + TokenURL: "https://oauth.pipedrive.com/oauth/token", + }, []string{"contacts:full", "deals:full"}, false, identifyPipedrive) + + register(models.IntegrationSalesforce, "SALESFORCE", oauth2.Endpoint{ + AuthURL: "https://login.salesforce.com/services/oauth2/authorize", + TokenURL: "https://login.salesforce.com/services/oauth2/token", + }, []string{"api", "refresh_token"}, true, identifyGeneric) + + return m +} + +// SupportsOAuth reports whether the provider has an OAuth flow at all. +func (m *OAuthManager) SupportsOAuth(p models.IntegrationProvider) bool { + _, ok := m.providers[p] + return ok +} + +// Configured reports whether the provider has client credentials wired. +func (m *OAuthManager) Configured(p models.IntegrationProvider) bool { + op, ok := m.providers[p] + return ok && op.config != nil +} + +// Scopes returns the requested scopes for a provider (empty if none/unknown). +func (m *OAuthManager) Scopes(p models.IntegrationProvider) []string { + if op, ok := m.providers[p]; ok { + return op.scopes + } + return nil +} + +// AuthCodeURL builds the provider authorization URL. It returns the URL plus +// the PKCE verifier to persist (empty when the provider doesn't use PKCE). +func (m *OAuthManager) AuthCodeURL(p models.IntegrationProvider, state string) (authURL, verifier string, err error) { + op, ok := m.providers[p] + if !ok || op.config == nil { + return "", "", fmt.Errorf("oauth not configured for provider %s", p) + } + opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline, oauth2.ApprovalForce} + if op.usePKCE { + verifier = randomURLToken(32) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + opts = append(opts, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + } + return op.config.AuthCodeURL(state, opts...), verifier, nil +} + +// Exchange swaps an authorization code for tokens and resolves the connected +// account identity. +func (m *OAuthManager) Exchange(ctx context.Context, p models.IntegrationProvider, code, verifier string) (*models.IntegrationTokens, extAccount, error) { + op, ok := m.providers[p] + if !ok || op.config == nil { + return nil, extAccount{}, fmt.Errorf("oauth not configured for provider %s", p) + } + var opts []oauth2.AuthCodeOption + if op.usePKCE && verifier != "" { + opts = append(opts, oauth2.SetAuthURLParam("code_verifier", verifier)) + } + tok, err := op.config.Exchange(ctx, code, opts...) + if err != nil { + return nil, extAccount{}, fmt.Errorf("token exchange failed: %w", err) + } + + extID, extName, grantedScopes, idErr := "", "", []string(nil), error(nil) + if op.identify != nil { + extID, extName, grantedScopes, idErr = op.identify(ctx, m, tok) + if idErr != nil { + // Identity is best-effort: a connected token is still usable even + // if the profile lookup hiccups. We just won't show the account name. + grantedScopes = nil + } + } + if len(grantedScopes) == 0 { + grantedScopes = scopesFromToken(tok, op.scopes) + } + + tokens := &models.IntegrationTokens{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Scopes: grantedScopes, + } + if !tok.Expiry.IsZero() { + exp := tok.Expiry.UTC() + tokens.ExpiresAt = &exp + } + return tokens, extAccount{ID: extID, Name: extName}, nil +} + +// RefreshIfNeeded returns a valid access token for the connection, refreshing +// via the stored refresh token when the access token is within 60s of expiry. +// It reports whether the token was refreshed (so the caller can persist it). +func (m *OAuthManager) RefreshIfNeeded(ctx context.Context, p models.IntegrationProvider, current models.IntegrationTokens) (models.IntegrationTokens, bool, error) { + op, ok := m.providers[p] + if !ok || op.config == nil { + return current, false, fmt.Errorf("oauth not configured for provider %s", p) + } + stillValid := current.ExpiresAt == nil || time.Until(*current.ExpiresAt) > 60*time.Second + if stillValid || current.RefreshToken == "" { + return current, false, nil + } + + src := op.config.TokenSource(ctx, &oauth2.Token{ + AccessToken: current.AccessToken, + RefreshToken: current.RefreshToken, + Expiry: time.Now().Add(-time.Minute), + }) + tok, err := src.Token() + if err != nil { + return current, false, fmt.Errorf("token refresh failed: %w", err) + } + refreshed := models.IntegrationTokens{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Scopes: current.Scopes, + } + if refreshed.RefreshToken == "" { + refreshed.RefreshToken = current.RefreshToken // some providers omit it on refresh + } + if !tok.Expiry.IsZero() { + exp := tok.Expiry.UTC() + refreshed.ExpiresAt = &exp + } + return refreshed, true, nil +} + +// extAccount is the resolved external identity for a connection. +type extAccount struct { + ID string + Name string +} + +// --- identity resolvers ----------------------------------------------------- + +func identifyHubSpot(ctx context.Context, m *OAuthManager, tok *oauth2.Token) (string, string, []string, error) { + var out struct { + HubID int64 `json:"hub_id"` + HubDomain string `json:"hub_domain"` + User string `json:"user"` + Scopes []string `json:"scopes"` + } + url := "https://api.hubapi.com/oauth/v1/access-tokens/" + tok.AccessToken + if err := m.getJSON(ctx, url, "", &out); err != nil { + return "", "", nil, err + } + name := out.HubDomain + if name == "" { + name = out.User + } + return fmt.Sprintf("%d", out.HubID), name, out.Scopes, nil +} + +func identifySlack(ctx context.Context, m *OAuthManager, tok *oauth2.Token) (string, string, []string, error) { + var out struct { + OK bool `json:"ok"` + Team string `json:"team"` + TeamID string `json:"team_id"` + URL string `json:"url"` + Error string `json:"error"` + } + if err := m.getJSON(ctx, "https://slack.com/api/auth.test", tok.AccessToken, &out); err != nil { + return "", "", nil, err + } + if !out.OK { + return "", "", nil, fmt.Errorf("slack auth.test: %s", out.Error) + } + return out.TeamID, out.Team, nil, nil +} + +func identifyGoogle(ctx context.Context, m *OAuthManager, tok *oauth2.Token) (string, string, []string, error) { + var out struct { + Email string `json:"email"` + ID string `json:"id"` + } + if err := m.getJSON(ctx, "https://www.googleapis.com/oauth2/v2/userinfo", tok.AccessToken, &out); err != nil { + return "", "", nil, err + } + return out.ID, out.Email, nil, nil +} + +func identifyPipedrive(ctx context.Context, m *OAuthManager, tok *oauth2.Token) (string, string, []string, error) { + var out struct { + Data struct { + ID int64 `json:"id"` + Name string `json:"name"` + CompanyName string `json:"company_name"` + Email string `json:"email"` + } `json:"data"` + } + if err := m.getJSON(ctx, "https://api.pipedrive.com/v1/users/me", tok.AccessToken, &out); err != nil { + return "", "", nil, err + } + name := out.Data.CompanyName + if name == "" { + name = out.Data.Email + } + return fmt.Sprintf("%d", out.Data.ID), name, nil, nil +} + +// identifyGeneric is the fallback for providers whose identity lookup we don't +// model yet (e.g. Salesforce). The connection still works; it just shows no +// external account name until a richer resolver lands. +func identifyGeneric(_ context.Context, _ *OAuthManager, _ *oauth2.Token) (string, string, []string, error) { + return "", "", nil, nil +} + +// --- helpers ---------------------------------------------------------------- + +func (m *OAuthManager) getJSON(ctx context.Context, url, bearer string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := m.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + return json.Unmarshal(body, dst) +} + +// scopesFromToken pulls the granted scopes out of the token's "scope" extra +// field (space- or comma-delimited), falling back to the requested scopes. +func scopesFromToken(tok *oauth2.Token, requested []string) []string { + raw, _ := tok.Extra("scope").(string) + raw = strings.TrimSpace(raw) + if raw == "" { + return requested + } + sep := " " + if strings.Contains(raw, ",") && !strings.Contains(raw, " ") { + sep = "," + } + parts := strings.Split(raw, sep) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return requested + } + return out +} + +func randomURLToken(n int) string { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + // rand.Read essentially never fails; degrade to a time-seeded value + // only to keep the flow alive rather than panic. + return base64.RawURLEncoding.EncodeToString([]byte(time.Now().UTC().String())) + } + return base64.RawURLEncoding.EncodeToString(buf) +} diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index eec3229c..76f93ef2 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -5,112 +5,194 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "strings" + "time" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/app/cipher" + "github.com/warmbly/warmbly/internal/app/webhook" "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. +// oauthStateTTL bounds how long a started OAuth handshake stays valid. +const oauthStateTTL = 15 * time.Minute + +// ErrOAuthNotConfigured is returned when a provider's OAuth client credentials +// are not present in the environment. +var ErrOAuthNotConfigured = errors.New("oauth is not configured for this provider") + +// ErrUseOAuth is returned when a caller tries to paste credentials for a +// provider that should be connected via the OAuth handshake instead. +var ErrUseOAuth = errors.New("this provider connects via OAuth; start the authorize flow instead") + +// Service exposes the integration surface the dashboard and event pipeline +// talk to. Provider-specific behaviour (OAuth identity, event actions, inbound +// webhooks) 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) + GetConnection(ctx context.Context, orgID, id 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) + // Connect registers a credential-based connection (api-key / webhook-URL + // providers). OAuth providers must use OAuthStart instead. The config map's + // secret values are sealed with the connecting user's envelope DEK before + // they touch the database. + Connect(ctx context.Context, orgID, userID 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. + // OAuthStart returns the provider authorization URL for a one-click connect. + OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error) + // OAuthFinish completes the handshake: validates state, exchanges the code, + // resolves the account identity, and persists encrypted tokens. + OAuthFinish(ctx context.Context, userID uuid.UUID, code, state string) (*models.IntegrationConnection, error) + // Reauth starts a fresh OAuth handshake for an existing connection whose + // token expired or was revoked. + Reauth(ctx context.Context, orgID, userID, id uuid.UUID) (*models.IntegrationOAuthStartResponse, error) + + // RotateInboundSecret regenerates the inbound URL secret (Calendly/Cal.com). 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. + // Event subscriptions wire a Warmbly event to a provider action. + ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) + CreateEventSubscription(ctx context.Context, orgID, connID uuid.UUID, eventType string, action models.IntegrationAction, config map[string]any, enabled bool) (*models.IntegrationEventSubscription, error) + DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error + + // ListSyncRuns returns recent observability records for a connection. + ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) + + // MarkSynced records a successful/failed round-trip against a connection. 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. + // Dispatch fans a platform event out to every matching event subscription, + // executing each provider action. Best-effort: action failures are recorded + // on the connection's health but never block the caller. + Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) + + // DispatchAny is the loosely-typed adapter wired into the webhook fan-out + // sink. It forwards only map-shaped payloads (the common event shape) to + // Dispatch; struct payloads are ignored. + DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) + + // Repo exposes the underlying repository for the inbound webhook handlers. Repo() repository.IntegrationRepository } type service struct { - repo repository.IntegrationRepository + repo repository.IntegrationRepository + cipher cipher.CipherService + oauth *OAuthManager } -func NewService(repo repository.IntegrationRepository) Service { - return &service{repo: repo} +// NewService builds the integration service. cipherSvc seals provider secrets +// with the connecting user's envelope DEK; oauth drives the OAuth handshakes. +func NewService(repo repository.IntegrationRepository, cipherSvc cipher.CipherService, oauth *OAuthManager) Service { + if oauth == nil { + oauth = NewOAuthManager() + } + return &service{repo: repo, cipher: cipherSvc, oauth: oauth} } func (s *service) Repo() repository.IntegrationRepository { return s.repo } -func (s *service) Catalog() []models.IntegrationCatalogEntry { return Catalog() } +func (s *service) Catalog() []models.IntegrationCatalogEntry { + entries := Catalog() + for i := range entries { + e := &entries[i] + if e.AuthMethod == string(models.IntegrationAuthOAuth) { + e.Configured = s.oauth.Configured(e.Provider) + if len(e.Scopes) == 0 { + e.Scopes = s.oauth.Scopes(e.Provider) + } + } else { + // api-key and webhook providers are always usable. + e.Configured = true + } + } + return entries +} 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) { +func (s *service) GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) { + return s.repo.GetConnectionByID(ctx, orgID, id) +} + +func (s *service) Connect(ctx context.Context, orgID, userID 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) } + authMethod := catalogAuthMethod(provider) + if authMethod == string(models.IntegrationAuthOAuth) { + // Credential pasting is not allowed for OAuth providers. + return nil, ErrUseOAuth + } + label = strings.TrimSpace(label) if label == "" { label = string(provider) } + // SSRF guard: any user-supplied outbound URL we'll later POST to must be + // HTTPS + publicly routable, matching the customer-webhook policy. + if err := validateOutboundConfigURLs(config); err != nil { + return nil, err + } + 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 { + if provider == models.IntegrationCalendly || provider == models.IntegrationCalCom { inboundSecret, err = generateInboundSecret(provider) if err != nil { return nil, err } } - encrypted, err := encodeConfig(config) + configEnc, err := s.sealConfig(ctx, userID, 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. + case models.IntegrationCalendly, models.IntegrationCalCom: + // Inbound providers are "connected" once the URL exists. 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 { + if hasAnyCredential(config) { status = models.IntegrationStatusConnected } } df, _ := json.Marshal(displayFields) conn := &models.IntegrationConnection{ - OrganizationID: orgID, - Provider: provider, - Label: label, - Status: status, - DisplayFields: df, + OrganizationID: orgID, + Provider: provider, + Label: label, + Status: status, + AuthMethod: authMethod, + DisplayFields: df, + ConnectedByUserID: &userID, + Health: string(models.IntegrationHealthUnknown), } - if err := s.repo.UpsertConnection(ctx, conn, encrypted, inboundSecret); err != nil { + if status == models.IntegrationStatusConnected { + conn.Health = string(models.IntegrationHealthHealthy) + now := time.Now().UTC() + conn.HealthCheckedAt = &now + } + + if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{ + Conn: conn, + ConfigEncrypted: configEnc, + InboundSecret: inboundSecret, + }); err != nil { return nil, err } @@ -124,6 +206,129 @@ func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error { return s.repo.DeleteConnection(ctx, orgID, id) } +func (s *service) OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error) { + if !s.oauth.Configured(provider) { + return nil, ErrOAuthNotConfigured + } + state := randomURLToken(24) + authURL, verifier, err := s.oauth.AuthCodeURL(provider, state) + if err != nil { + return nil, err + } + + st := &models.IntegrationOAuthState{ + OrganizationID: orgID, + UserID: userID, + Provider: provider, + State: state, + CodeVerifier: verifier, + Label: strings.TrimSpace(label), + RequestedScopes: s.oauth.Scopes(provider), + ExpiresAt: time.Now().UTC().Add(oauthStateTTL), + } + if err := s.repo.CreateOAuthState(ctx, st); err != nil { + return nil, err + } + return &models.IntegrationOAuthStartResponse{URL: authURL, State: state}, nil +} + +func (s *service) OAuthFinish(ctx context.Context, userID uuid.UUID, code, state string) (*models.IntegrationConnection, error) { + code = strings.TrimSpace(code) + state = strings.TrimSpace(state) + if code == "" || state == "" { + return nil, errors.New("missing code or state") + } + + st, err := s.repo.TakeOAuthState(ctx, state) + if err != nil { + return nil, err + } + if st == nil { + return nil, errors.New("invalid or expired oauth state") + } + if st.UserID != userID { + return nil, errors.New("oauth state does not belong to this user") + } + + tokens, account, err := s.oauth.Exchange(ctx, st.Provider, code, st.CodeVerifier) + if err != nil { + return nil, err + } + + accessEnc, err := s.seal(ctx, userID, tokens.AccessToken) + if err != nil { + return nil, err + } + refreshEnc, err := s.seal(ctx, userID, tokens.RefreshToken) + if err != nil { + return nil, err + } + + label := st.Label + if label == "" { + label = string(st.Provider) + } + + display := map[string]any{} + if account.Name != "" { + display["account"] = account.Name + } + df, _ := json.Marshal(display) + + now := time.Now().UTC() + conn := &models.IntegrationConnection{ + OrganizationID: st.OrganizationID, + Provider: st.Provider, + Label: label, + Status: models.IntegrationStatusConnected, + AuthMethod: string(models.IntegrationAuthOAuth), + DisplayFields: df, + ConnectedByUserID: &userID, + ExternalAccountID: account.ID, + ExternalAccountName: account.Name, + GrantedScopes: tokens.Scopes, + TokenExpiresAt: tokens.ExpiresAt, + Health: string(models.IntegrationHealthHealthy), + HealthCheckedAt: &now, + } + if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{ + Conn: conn, + AccessTokenEnc: accessEnc, + RefreshTokenEnc: refreshEnc, + }); err != nil { + return nil, err + } + + // Re-read so the caller gets the canonical row (id, timestamps). + stored, err := s.repo.GetConnection(ctx, st.OrganizationID, st.Provider, label) + if err == nil && stored != nil { + _ = s.repo.CreateSyncRun(ctx, &models.IntegrationSyncRun{ + ConnectionID: stored.ID, + OrganizationID: st.OrganizationID, + Kind: "oauth_connect", + Status: "success", + Detail: "authorized " + string(st.Provider), + }) + return stored, nil + } + return conn, nil +} + +func (s *service) Reauth(ctx context.Context, orgID, userID, id uuid.UUID) (*models.IntegrationOAuthStartResponse, error) { + conn, err := s.repo.GetConnectionByID(ctx, orgID, id) + if err != nil { + return nil, err + } + if conn == nil { + return nil, errors.New("connection not found") + } + if conn.AuthMethod != string(models.IntegrationAuthOAuth) { + return nil, ErrUseOAuth + } + _ = s.repo.SetConnectionStatus(ctx, id, models.IntegrationStatusAuthorizing, models.IntegrationHealthDegraded, "reauthorizing") + return s.OAuthStart(ctx, orgID, userID, conn.Provider, conn.Label) +} + func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) { secret, err := generateInboundSecret(provider) if err != nil { @@ -134,19 +339,206 @@ func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, OrganizationID: orgID, Provider: provider, Status: models.IntegrationStatusConnected, + AuthMethod: string(models.IntegrationAuthWebhook), + Health: string(models.IntegrationHealthHealthy), } - if err := s.repo.UpsertConnection(ctx, conn, nil, secret); err != nil { + if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{Conn: conn, InboundSecret: secret}); err != nil { return "", err } return BuildInboundURL(provider, secret), nil } +func (s *service) ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) { + return s.repo.ListEventSubscriptions(ctx, orgID, connID) +} + +func (s *service) CreateEventSubscription(ctx context.Context, orgID, connID uuid.UUID, eventType string, action models.IntegrationAction, config map[string]any, enabled bool) (*models.IntegrationEventSubscription, error) { + conn, err := s.repo.GetConnectionByID(ctx, orgID, connID) + if err != nil { + return nil, err + } + if conn == nil { + return nil, errors.New("connection not found") + } + if !models.IsValidWebhookEventType(eventType) { + return nil, fmt.Errorf("unknown event type: %s", eventType) + } + // SSRF guard for action configs that carry an outbound URL. + if err := validateOutboundConfigURLs(config); err != nil { + return nil, err + } + cfg, _ := json.Marshal(config) + sub := &models.IntegrationEventSubscription{ + ConnectionID: connID, + OrganizationID: orgID, + EventType: eventType, + Action: action, + Config: cfg, + Enabled: enabled, + } + if err := s.repo.CreateEventSubscription(ctx, sub); err != nil { + return nil, err + } + return sub, nil +} + +func (s *service) DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error { + return s.repo.DeleteEventSubscription(ctx, orgID, id) +} + +func (s *service) ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) { + return s.repo.ListSyncRuns(ctx, orgID, connID, limit) +} + 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. +// --- encryption helpers ----------------------------------------------------- + +func (s *service) seal(ctx context.Context, userID uuid.UUID, plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + if s.cipher == nil { + return "", errors.New("cipher service unavailable") + } + c, err := s.cipher.Cipher(ctx, userID) + if err != nil { + return "", err + } + return c.Encrypt(ctx, plaintext) +} + +func (s *service) open(ctx context.Context, userID uuid.UUID, ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + if s.cipher == nil { + return "", errors.New("cipher service unavailable") + } + c, err := s.cipher.Cipher(ctx, userID) + if err != nil { + return "", err + } + return c.Decrypt(ctx, ciphertext) +} + +func (s *service) sealConfig(ctx context.Context, userID uuid.UUID, config map[string]any) ([]byte, error) { + if len(config) == 0 { + return nil, nil + } + raw, err := json.Marshal(config) + if err != nil { + return nil, err + } + b64, err := s.seal(ctx, userID, string(raw)) + if err != nil { + return nil, err + } + return []byte(b64), nil +} + +func (s *service) openConfig(ctx context.Context, sec *repository.ConnectionSecrets) (map[string]any, error) { + if len(sec.ConfigEncrypted) == 0 || sec.Conn.ConnectedByUserID == nil { + return map[string]any{}, nil + } + plain, err := s.open(ctx, *sec.Conn.ConnectedByUserID, string(sec.ConfigEncrypted)) + if err != nil { + return nil, err + } + out := map[string]any{} + if plain == "" { + return out, nil + } + if err := json.Unmarshal([]byte(plain), &out); err != nil { + return nil, err + } + return out, nil +} + +// accessTokenFor decrypts the connection's access token, refreshing via the +// stored refresh token when near expiry and persisting the refreshed pair. +// On an unrecoverable refresh failure it flips the connection to +// reauth_required and returns an error. +func (s *service) accessTokenFor(ctx context.Context, sec *repository.ConnectionSecrets) (string, error) { + if sec.Conn.ConnectedByUserID == nil { + return "", errors.New("connection has no owning user for decryption") + } + userID := *sec.Conn.ConnectedByUserID + + access, err := s.open(ctx, userID, sec.AccessTokenEnc) + if err != nil { + return "", err + } + refresh, err := s.open(ctx, userID, sec.RefreshTokenEnc) + if err != nil { + return "", err + } + + current := models.IntegrationTokens{ + AccessToken: access, + RefreshToken: refresh, + ExpiresAt: sec.Conn.TokenExpiresAt, + Scopes: sec.Conn.GrantedScopes, + } + refreshed, didRefresh, rerr := s.oauth.RefreshIfNeeded(ctx, sec.Conn.Provider, current) + if rerr != nil { + _ = s.repo.SetConnectionStatus(ctx, sec.Conn.ID, models.IntegrationStatusReauthRequired, models.IntegrationHealthDown, "token refresh failed: reconnect required") + return "", rerr + } + if didRefresh { + accessEnc, _ := s.seal(ctx, userID, refreshed.AccessToken) + refreshEnc, _ := s.seal(ctx, userID, refreshed.RefreshToken) + _ = s.repo.UpdateConnectionTokens(ctx, sec.Conn.ID, accessEnc, refreshEnc, refreshed.ExpiresAt, refreshed.Scopes) + return refreshed.AccessToken, nil + } + return refreshed.AccessToken, nil +} + +// --- shared helpers --------------------------------------------------------- + +// validateOutboundConfigURLs enforces the SSRF/HTTPS policy on any config value +// under a url-bearing key (webhook_url / url) we will later POST to. +func validateOutboundConfigURLs(config map[string]any) error { + for _, k := range []string{"webhook_url", "url"} { + v, ok := config[k] + if !ok { + continue + } + s, ok := v.(string) + if !ok || strings.TrimSpace(s) == "" { + continue + } + if err := webhook.ValidateOutboundURL(s); err != nil { + return fmt.Errorf("%s: %w", k, err) + } + } + return nil +} + +func hasAnyCredential(config map[string]any) bool { + for _, k := range []string{"api_token", "access_token", "webhook_url", "api_key"} { + if v, ok := config[k]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return true + } + } + } + return false +} + +func catalogAuthMethod(provider models.IntegrationProvider) string { + for _, e := range Catalog() { + if e.Provider == provider { + return e.AuthMethod + } + } + return string(models.IntegrationAuthAPIKey) +} + +// generateInboundSecret returns a prefixed 24-byte hex string. func generateInboundSecret(provider models.IntegrationProvider) (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { @@ -162,8 +554,8 @@ func generateInboundSecret(provider models.IntegrationProvider) (string, error) 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. +// BuildInboundURL is exported so the routes file and handler tests can generate +// the same URL the dashboard surfaces. func BuildInboundURL(provider models.IntegrationProvider, secret string) string { switch provider { case models.IntegrationCalendly: @@ -174,54 +566,30 @@ func BuildInboundURL(provider models.IntegrationProvider, secret string) string 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. +// buildDisplayFields extracts the public, non-secret bits of the config that +// the dashboard surfaces next to a connection card. func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any { df := map[string]any{} + pick := func(keys ...string) { + for _, k := range keys { + if v, ok := config[k]; ok { + df[k] = v + } + } + } switch provider { case models.IntegrationCalendly, models.IntegrationCalCom: - if v, ok := config["organization_uri"]; ok { - df["organization_uri"] = v - } + pick("organization_uri") 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 - } + pick("sheet_id", "sheet_title") 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 - } + pick("workspace", "account_email") case models.IntegrationSlack: - if v, ok := config["workspace"]; ok { - df["workspace"] = v - } - if v, ok := config["channel"]; ok { - df["channel"] = v - } + pick("workspace", "channel") case models.IntegrationDiscord: - if v, ok := config["server"]; ok { - df["server"] = v - } + pick("server") 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. + // Outbound-via-Warmbly-API providers: minimal display fields. } return df } diff --git a/internal/app/webhook/service.go b/internal/app/webhook/service.go index 81ae4b3d..687a45ea 100644 --- a/internal/app/webhook/service.go +++ b/internal/app/webhook/service.go @@ -40,6 +40,12 @@ const EventHeader = "X-Warmbly-Event" // dedupe replays. const EventIDHeader = "X-Warmbly-Event-Id" +// DispatchSink receives every dispatched event so other subsystems (notably +// third-party integration actions: Slack pings, CRM upserts) can react to the +// same event vocabulary that drives customer webhooks, without every call site +// having to know about them. Wired optionally via WireDispatchSink. +type DispatchSink func(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) + // Service is the call-site API. Internal events call Dispatch — the // service writes one delivery row per matching endpoint and returns // immediately. The DeliveryWorker drains the queue asynchronously. @@ -49,6 +55,11 @@ type Service interface { // log/audit. If no endpoints match, this is a no-op. Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) + // WireDispatchSink attaches a single fan-out sink invoked for every + // dispatched event (even when no webhook endpoint matches). Idempotent + // replacement; pass nil to detach. + WireDispatchSink(sink DispatchSink) + // Endpoint CRUD wrappers. CreateEndpoint(ctx context.Context, orgID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpointWithSecret, error) UpdateEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpoint, error) @@ -61,15 +72,26 @@ type Service interface { type service struct { repo repository.WebhookRepository now func() time.Time + sink DispatchSink } func NewService(repo repository.WebhookRepository) Service { return &service{repo: repo, now: time.Now} } +func (s *service) WireDispatchSink(sink DispatchSink) { + s.sink = sink +} + func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) { eventID := uuid.New() + // Fan the event to non-webhook subscribers (integration actions) first, + // independently of whether any webhook endpoint is configured. + if s.sink != nil { + s.sink(ctx, orgID, eventType, data) + } + endpoints, err := s.repo.MatchingEndpoints(ctx, orgID, eventType) if err != nil { return eventID, err @@ -187,6 +209,15 @@ func generateSecret() (string, error) { return "whsec_" + hex.EncodeToString(buf), nil } +// ValidateOutboundURL is the exported SSRF/HTTPS guard reused by any subsystem +// that stores a user-supplied URL we will later POST to (e.g. third-party +// integration actions such as Discord/generic webhooks). Same policy as the +// customer-webhook endpoints: HTTPS + publicly-routable host, unless +// WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS=true for local/self-hosted development. +func ValidateOutboundURL(raw string) error { + return validateURL(raw) +} + // validateURL keeps malformed entries and obvious SSRF targets out of the // table. Public webhook endpoints must use HTTPS and route to public hosts. // Local/self-hosted development can set WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS=true diff --git a/internal/infrastructure/db/migrations/000060_integrations_oauth.down.sql b/internal/infrastructure/db/migrations/000060_integrations_oauth.down.sql new file mode 100644 index 00000000..52f30f39 --- /dev/null +++ b/internal/infrastructure/db/migrations/000060_integrations_oauth.down.sql @@ -0,0 +1,22 @@ +DROP TABLE IF EXISTS integration_sync_runs; +DROP TABLE IF EXISTS integration_field_mappings; +DROP TABLE IF EXISTS integration_event_subscriptions; +DROP TABLE IF EXISTS integration_oauth_states; + +ALTER TABLE integration_connections DROP CONSTRAINT IF EXISTS integration_connections_status_check; +ALTER TABLE integration_connections + ADD CONSTRAINT integration_connections_status_check + CHECK (status IN ('pending', 'connected', 'degraded', 'disconnected')); + +ALTER TABLE integration_connections + DROP COLUMN IF EXISTS connected_by_user_id, + DROP COLUMN IF EXISTS auth_method, + DROP COLUMN IF EXISTS access_token_encrypted, + DROP COLUMN IF EXISTS refresh_token_encrypted, + DROP COLUMN IF EXISTS token_expires_at, + DROP COLUMN IF EXISTS granted_scopes, + DROP COLUMN IF EXISTS external_account_id, + DROP COLUMN IF EXISTS external_account_name, + DROP COLUMN IF EXISTS health, + DROP COLUMN IF EXISTS health_detail, + DROP COLUMN IF EXISTS health_checked_at; diff --git a/internal/infrastructure/db/migrations/000060_integrations_oauth.up.sql b/internal/infrastructure/db/migrations/000060_integrations_oauth.up.sql new file mode 100644 index 00000000..42e746af --- /dev/null +++ b/internal/infrastructure/db/migrations/000060_integrations_oauth.up.sql @@ -0,0 +1,121 @@ +-- Enterprise integrations: real OAuth connect flows, encrypted-at-rest +-- provider secrets, a richer connection lifecycle + health model, event-driven +-- actions (e.g. positive reply -> Slack ping / HubSpot contact), CRM field +-- mappings, and sync-run history. Builds on 000053 (integration_connections, +-- meeting_bookings). +-- +-- Secret handling: access/refresh tokens are sealed with the connecting user's +-- envelope-encryption DEK (KMS -> per-user DEK -> AES-GCM, same path the email +-- mailbox OAuth tokens use) and stored as base64 ciphertext. Plaintext secrets +-- never land in any column here. The legacy config_encrypted blob is likewise +-- now sealed in app code rather than stored as plaintext JSON. + +-- --------------------------------------------------------------------------- +-- 1. Extend integration_connections with OAuth + lifecycle + health columns. +-- --------------------------------------------------------------------------- +ALTER TABLE integration_connections + ADD COLUMN IF NOT EXISTS connected_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS auth_method TEXT NOT NULL DEFAULT 'api_key', + ADD COLUMN IF NOT EXISTS access_token_encrypted TEXT, + ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT, + ADD COLUMN IF NOT EXISTS token_expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS granted_scopes TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS external_account_id TEXT, + ADD COLUMN IF NOT EXISTS external_account_name TEXT, + ADD COLUMN IF NOT EXISTS health TEXT NOT NULL DEFAULT 'unknown', + ADD COLUMN IF NOT EXISTS health_detail TEXT, + ADD COLUMN IF NOT EXISTS health_checked_at TIMESTAMPTZ; + +-- Widen the lifecycle: 'authorizing' (OAuth handshake mid-flight) and +-- 'reauth_required' (token revoked/expired; the user must reconnect). +ALTER TABLE integration_connections DROP CONSTRAINT IF EXISTS integration_connections_status_check; +ALTER TABLE integration_connections + ADD CONSTRAINT integration_connections_status_check + CHECK (status IN ('pending', 'authorizing', 'connected', 'degraded', 'reauth_required', 'disconnected')); + +-- --------------------------------------------------------------------------- +-- 2. OAuth handshake state (CSRF protection + PKCE). One short-lived row per +-- connect attempt; consumed on callback. +-- --------------------------------------------------------------------------- +CREATE TABLE integration_oauth_states ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + + -- Opaque random nonce echoed back by the provider; the join key on callback. + state TEXT NOT NULL UNIQUE, + + -- PKCE verifier (RFC 7636). Empty for providers that don't support PKCE. + code_verifier TEXT NOT NULL DEFAULT '', + + -- User-chosen connection label, carried through the round trip. + label TEXT NOT NULL DEFAULT '', + requested_scopes TEXT[] NOT NULL DEFAULT '{}', + + used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_integration_oauth_states_expires ON integration_oauth_states (expires_at); + +-- --------------------------------------------------------------------------- +-- 3. Event-driven actions. Maps a Warmbly platform event to a provider action +-- on a connection, e.g. campaign.reply_received -> slack.notify (channel) +-- or hubspot.upsert_contact. Non-secret action params live in config. +-- --------------------------------------------------------------------------- +CREATE TABLE integration_event_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + event_type TEXT NOT NULL, -- a models.WebhookEventType value (shared event vocabulary) + action TEXT NOT NULL, -- provider action key, e.g. 'slack.notify', 'hubspot.upsert_contact' + config JSONB NOT NULL DEFAULT '{}'::jsonb, + + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (connection_id, event_type, action) +); + +CREATE INDEX idx_integration_event_subs_dispatch + ON integration_event_subscriptions (organization_id, event_type) + WHERE enabled; + +-- --------------------------------------------------------------------------- +-- 4. CRM field mappings (Warmbly contact field <-> external object field). +-- --------------------------------------------------------------------------- +CREATE TABLE integration_field_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + direction TEXT NOT NULL DEFAULT 'push' CHECK (direction IN ('push', 'pull', 'both')), + warmbly_field TEXT NOT NULL, + external_field TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (connection_id, direction, warmbly_field, external_field) +); + +-- --------------------------------------------------------------------------- +-- 5. Sync-run history for observability (connect, token refresh, dispatch). +-- --------------------------------------------------------------------------- +CREATE TABLE integration_sync_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + kind TEXT NOT NULL, -- 'oauth_connect','token_refresh','event_dispatch','manual_sync' + status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'error')), + detail TEXT NOT NULL DEFAULT '', + records_processed INT NOT NULL DEFAULT 0, + + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ +); + +CREATE INDEX idx_integration_sync_runs_conn ON integration_sync_runs (connection_id, started_at DESC); diff --git a/internal/models/audit.go b/internal/models/audit.go index 1b1f6382..117d4721 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -49,6 +49,7 @@ const ( AuditEntityAWSCredentials AuditEntityType = "aws_credentials" AuditEntityWorkerProfile AuditEntityType = "worker_profile" AuditEntityRelease AuditEntityType = "release" + AuditEntityIntegration AuditEntityType = "integration" ) type AuditLog struct { diff --git a/internal/models/integration.go b/internal/models/integration.go index 7339da92..30614a75 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -63,16 +63,52 @@ func IsValidIntegrationProvider(s string) bool { return false } -// IntegrationStatus is the operational health of a connection. +// IntegrationAuthMethod describes how a connection is authenticated. +type IntegrationAuthMethod string + +const ( + // IntegrationAuthOAuth is a real OAuth 2.0 authorization-code handshake. + // The user clicks "Connect", authorizes in the provider's popup, and we + // store an encrypted access/refresh token pair — no pasting credentials. + IntegrationAuthOAuth IntegrationAuthMethod = "oauth" + // IntegrationAuthAPIKey is a provider-issued API token the user pastes + // (used only where the provider offers no OAuth app, e.g. Close). + IntegrationAuthAPIKey IntegrationAuthMethod = "api_key" + // IntegrationAuthWebhook is an inbound URL Warmbly mints (Calendly, Cal.com) + // or an outbound URL the user pastes (Discord). + IntegrationAuthWebhook IntegrationAuthMethod = "webhook" +) + +// IntegrationStatus is the lifecycle state of a connection. type IntegrationStatus string const ( - IntegrationStatusPending IntegrationStatus = "pending" - IntegrationStatusConnected IntegrationStatus = "connected" - IntegrationStatusDegraded IntegrationStatus = "degraded" + // IntegrationStatusPending — row created, not yet usable. + IntegrationStatusPending IntegrationStatus = "pending" + // IntegrationStatusAuthorizing — OAuth handshake is mid-flight. + IntegrationStatusAuthorizing IntegrationStatus = "authorizing" + // IntegrationStatusConnected — healthy, last interaction succeeded. + IntegrationStatusConnected IntegrationStatus = "connected" + // IntegrationStatusDegraded — connected but the last call errored. + IntegrationStatusDegraded IntegrationStatus = "degraded" + // IntegrationStatusReauthRequired — token revoked/expired; user must reconnect. + IntegrationStatusReauthRequired IntegrationStatus = "reauth_required" + // IntegrationStatusDisconnected — intentionally removed. IntegrationStatusDisconnected IntegrationStatus = "disconnected" ) +// IntegrationHealth is the rolling health signal surfaced on the connection +// card, independent of the lifecycle status (a connection can be 'connected' +// but 'degraded' health after a transient provider error). +type IntegrationHealth string + +const ( + IntegrationHealthUnknown IntegrationHealth = "unknown" + IntegrationHealthHealthy IntegrationHealth = "healthy" + IntegrationHealthDegraded IntegrationHealth = "degraded" + IntegrationHealthDown IntegrationHealth = "down" +) + // IntegrationCategory groups providers in the dashboard. type IntegrationCategory string @@ -87,36 +123,142 @@ const ( // 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"` + 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 is shown to webhook-URL providers (Discord) and inbound + // providers (Calendly, Cal.com). + WebhookHint string `json:"webhook_hint,omitempty"` + + // Highlights are short "what you get" bullets shown on the provider's + // detail screen during onboarding. + Highlights []string `json:"highlights,omitempty"` + + // Scopes is the set of OAuth scopes requested at authorize time. Empty + // for non-OAuth providers. Surfaced so the consent screen is honest. + Scopes []string `json:"scopes,omitempty"` + + // Events lists the Warmbly events this provider can react to (so the UI + // can offer "notify on positive reply", etc). + Events []string `json:"events,omitempty"` + + // Configured reports whether the server has OAuth client credentials wired + // for this provider. OAuth providers without credentials render as + // "coming soon" instead of a dead Connect button. + Configured bool `json:"configured"` } -// IntegrationConnection is one org's link to one provider. +// IntegrationConnection is one org's link to one provider. Secrets +// (access/refresh tokens, pasted API keys) are never serialized here — only +// the encrypted columns in the DB hold them. 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"` + AuthMethod string `json:"auth_method"` 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"` + + ConnectedByUserID *uuid.UUID `json:"connected_by_user_id,omitempty"` + ExternalAccountID string `json:"external_account_id,omitempty"` + ExternalAccountName string `json:"external_account_name,omitempty"` + GrantedScopes []string `json:"granted_scopes,omitempty"` + TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` + + Health string `json:"health"` + HealthDetail *string `json:"health_detail,omitempty"` + HealthCheckedAt *time.Time `json:"health_checked_at,omitempty"` + + 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"` } +// IntegrationTokens carries the freshly-exchanged OAuth material an +// implementation persists. Plaintext lives only in memory. +type IntegrationTokens struct { + AccessToken string + RefreshToken string + ExpiresAt *time.Time + Scopes []string +} + +// IntegrationOAuthState is the short-lived CSRF/PKCE record minted at the +// start of an OAuth handshake and consumed on callback. +type IntegrationOAuthState struct { + ID uuid.UUID + OrganizationID uuid.UUID + UserID uuid.UUID + Provider IntegrationProvider + State string + CodeVerifier string + Label string + RequestedScopes []string + UsedAt *time.Time + ExpiresAt time.Time + CreatedAt time.Time +} + +// IntegrationOAuthStartResponse is returned to the SPA so it can open the +// provider authorization popup. +type IntegrationOAuthStartResponse struct { + URL string `json:"url"` + State string `json:"state"` +} + +// IntegrationAction is a provider-specific side effect fired by an event +// subscription. +type IntegrationAction string + +const ( + IntegrationActionSlackNotify IntegrationAction = "slack.notify" + IntegrationActionDiscordNotify IntegrationAction = "discord.notify" + IntegrationActionHubSpotUpsert IntegrationAction = "hubspot.upsert_contact" + IntegrationActionPipedriveUpsert IntegrationAction = "pipedrive.upsert_person" + IntegrationActionSheetsAppend IntegrationAction = "google_sheets.append_row" + IntegrationActionGenericWebhookPing IntegrationAction = "webhook.ping" +) + +// IntegrationEventSubscription routes a Warmbly event to a provider action. +type IntegrationEventSubscription struct { + ID uuid.UUID `json:"id"` + ConnectionID uuid.UUID `json:"connection_id"` + OrganizationID uuid.UUID `json:"organization_id"` + EventType string `json:"event_type"` + Action IntegrationAction `json:"action"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// IntegrationSyncRun is one observability record of work done against a +// connection (connect, token refresh, event dispatch, manual sync). +type IntegrationSyncRun struct { + ID uuid.UUID `json:"id"` + ConnectionID uuid.UUID `json:"connection_id"` + OrganizationID uuid.UUID `json:"organization_id"` + Kind string `json:"kind"` + Status string `json:"status"` + Detail string `json:"detail"` + RecordsProcessed int `json:"records_processed"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + // MeetingBooking represents one booked meeting from Calendly/Cal.com. type MeetingBooking struct { ID uuid.UUID `json:"id"` diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go index 0edb56b3..05a555cd 100644 --- a/internal/repository/pg_integration.go +++ b/internal/repository/pg_integration.go @@ -14,17 +14,65 @@ import ( "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. +// ConnectionWrite is the full upsert payload for an integration connection. +// Encrypted secret fields use empty-string / nil "leave unchanged" semantics +// on conflict so partial writes (e.g. rotating an inbound secret) don't wipe +// the rest of the connection. +type ConnectionWrite struct { + Conn *models.IntegrationConnection + ConfigEncrypted []byte // sealed JSON config (api-key / webhook providers) + AccessTokenEnc string // base64 ciphertext; "" = leave unchanged + RefreshTokenEnc string // base64 ciphertext; "" = leave unchanged + InboundSecret string // "" = leave unchanged +} + +// ConnectionSecrets carries the encrypted secret material for a connection so +// the service can decrypt it with the connecting user's DEK. Never serialized +// to the API. +type ConnectionSecrets struct { + Conn models.IntegrationConnection + ConfigEncrypted []byte + AccessTokenEnc string + RefreshTokenEnc string +} + +// DispatchTarget pairs an event subscription with the connection (and its +// secrets) needed to execute the action. +type DispatchTarget struct { + Subscription models.IntegrationEventSubscription + Secrets ConnectionSecrets +} + +// IntegrationRepository owns persistence for third-party integrations: +// connections + their encrypted secrets, OAuth handshake state, event-driven +// action subscriptions, sync-run history, and meeting bookings. type IntegrationRepository interface { // Connections - UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error + UpsertConnection(ctx context.Context, w *ConnectionWrite) 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) + GetConnectionByID(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) + GetConnectionSecrets(ctx context.Context, id uuid.UUID) (*ConnectionSecrets, 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 + UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error + SetConnectionStatus(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string) error + + // OAuth handshake state + CreateOAuthState(ctx context.Context, st *models.IntegrationOAuthState) error + TakeOAuthState(ctx context.Context, state string) (*models.IntegrationOAuthState, error) + + // Event subscriptions + CreateEventSubscription(ctx context.Context, sub *models.IntegrationEventSubscription) error + ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) + DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error + MatchingDispatchTargets(ctx context.Context, orgID uuid.UUID, eventType string) ([]DispatchTarget, error) + + // Sync runs + CreateSyncRun(ctx context.Context, run *models.IntegrationSyncRun) error + FinishSyncRun(ctx context.Context, id uuid.UUID, status, detail string, records int) error + ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) // Bookings UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error @@ -39,11 +87,16 @@ 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 { +// connectionPublicCols is the non-secret projection. Nullable TEXT columns are +// COALESCE'd so they scan into plain string fields. +const connectionPublicCols = ` + id, organization_id, provider, label, status, auth_method, display_fields, + connected_by_user_id, COALESCE(external_account_id, ''), COALESCE(external_account_name, ''), + granted_scopes, token_expires_at, health, health_detail, health_checked_at, + last_synced_at, last_error, last_error_at, created_at, updated_at` + +func (r *integrationRepository) UpsertConnection(ctx context.Context, w *ConnectionWrite) error { + c := w.Conn if c.ID == uuid.Nil { c.ID = uuid.New() } @@ -55,22 +108,53 @@ func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models. if len(display) == 0 { display = json.RawMessage("{}") } + if c.AuthMethod == "" { + c.AuthMethod = string(models.IntegrationAuthAPIKey) + } + if c.Health == "" { + c.Health = string(models.IntegrationHealthUnknown) + } _, err := r.db.Exec(ctx, ` INSERT INTO integration_connections ( - id, organization_id, provider, label, status, + id, organization_id, provider, label, status, auth_method, inbound_secret, config_encrypted, display_fields, + connected_by_user_id, access_token_encrypted, refresh_token_encrypted, + token_expires_at, granted_scopes, external_account_id, external_account_name, + health, health_detail, health_checked_at, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, + $13, $14, $15, $16, + $17, $18, $19, + $20, $20 + ) ON CONFLICT (organization_id, provider, label) DO UPDATE SET status = EXCLUDED.status, + auth_method = EXCLUDED.auth_method, 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, + connected_by_user_id = COALESCE(EXCLUDED.connected_by_user_id, integration_connections.connected_by_user_id), + access_token_encrypted = COALESCE(EXCLUDED.access_token_encrypted, integration_connections.access_token_encrypted), + refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, integration_connections.refresh_token_encrypted), + token_expires_at = COALESCE(EXCLUDED.token_expires_at, integration_connections.token_expires_at), + granted_scopes = EXCLUDED.granted_scopes, + external_account_id = COALESCE(EXCLUDED.external_account_id, integration_connections.external_account_id), + external_account_name = COALESCE(EXCLUDED.external_account_name, integration_connections.external_account_name), + health = EXCLUDED.health, + health_detail = EXCLUDED.health_detail, + health_checked_at = EXCLUDED.health_checked_at, updated_at = EXCLUDED.updated_at `, - c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status), - nullIfEmptyStr(inboundSecret), nullIfEmptyBytes(configEncrypted), display, now, + c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status), c.AuthMethod, + nullIfEmptyStr(w.InboundSecret), nullIfEmptyBytes(w.ConfigEncrypted), display, + c.ConnectedByUserID, nullIfEmptyStr(w.AccessTokenEnc), nullIfEmptyStr(w.RefreshTokenEnc), + c.TokenExpiresAt, normalizeScopes(c.GrantedScopes), nullIfEmptyStr(c.ExternalAccountID), nullIfEmptyStr(c.ExternalAccountName), + c.Health, c.HealthDetail, c.HealthCheckedAt, + now, ) return err } @@ -89,14 +173,16 @@ func nullIfEmptyBytes(b []byte) any { return b } +func normalizeScopes(s []string) []string { + if s == nil { + return []string{} + } + return s +} + 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) + rows, err := r.db.Query(ctx, `SELECT `+connectionPublicCols+` + FROM integration_connections WHERE organization_id = $1 ORDER BY created_at DESC`, orgID) if err != nil { return nil, err } @@ -104,55 +190,77 @@ func (r *integrationRepository) ListConnections(ctx context.Context, orgID uuid. out := []models.IntegrationConnection{} for rows.Next() { - c, err := scanConnection(rows) - if err != nil { + var c models.IntegrationConnection + if err := scanConnectionInto(rows, &c); err != nil { return nil, err } - out = append(out, *c) + 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 + row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+` + FROM integration_connections WHERE organization_id = $1 AND provider = $2 AND label = $3`, + orgID, string(provider), label) + var c models.IntegrationConnection + if err := scanConnectionInto(row, &c); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err } - return c, err + return &c, nil +} + +func (r *integrationRepository) GetConnectionByID(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) { + row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+` + FROM integration_connections WHERE organization_id = $1 AND id = $2`, orgID, id) + var c models.IntegrationConnection + if err := scanConnectionInto(row, &c); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err + } + return &c, nil +} + +func (r *integrationRepository) GetConnectionSecrets(ctx context.Context, id uuid.UUID) (*ConnectionSecrets, error) { + row := r.db.QueryRow(ctx, ` + SELECT `+connectionPublicCols+`, + config_encrypted, COALESCE(access_token_encrypted, ''), COALESCE(refresh_token_encrypted, '') + FROM integration_connections WHERE id = $1`, id) + var sec ConnectionSecrets + if err := scanConnectionSecretsInto(row, &sec); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err + } + return &sec, nil } -// 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 + row := r.db.QueryRow(ctx, `SELECT `+connectionPublicCols+` + FROM integration_connections WHERE provider = $1 AND inbound_secret = $2 LIMIT 1`, + string(provider), secret) + var c models.IntegrationConnection + if err := scanConnectionInto(row, &c); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err } - return c, err + return &c, nil } 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, - ) + `DELETE FROM integration_connections WHERE organization_id = $1 AND id = $2`, orgID, id) return err } @@ -165,42 +273,286 @@ func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uui _, err := r.db.Exec(ctx, ` UPDATE integration_connections SET status = $1, display_fields = $2, last_synced_at = $3, + health = 'healthy', health_detail = NULL, health_checked_at = $3, last_error = NULL, last_error_at = NULL, updated_at = $3 - WHERE id = $4 - `, string(status), displayFields, now, id) + WHERE id = $4`, string(status), displayFields, now, id) return err } _, err := r.db.Exec(ctx, ` UPDATE integration_connections SET status = $1, display_fields = $2, + health = 'degraded', health_detail = $3, health_checked_at = $4, last_error = $3, last_error_at = $4, updated_at = $4 - WHERE id = $5 - `, string(status), displayFields, errMsg, now, id) + WHERE id = $5`, string(status), displayFields, errMsg, now, id) return err } +func (r *integrationRepository) UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error { + now := time.Now().UTC() + _, err := r.db.Exec(ctx, ` + UPDATE integration_connections + SET access_token_encrypted = COALESCE($1, access_token_encrypted), + refresh_token_encrypted = COALESCE($2, refresh_token_encrypted), + token_expires_at = $3, + granted_scopes = CASE WHEN cardinality($4::text[]) > 0 THEN $4 ELSE granted_scopes END, + updated_at = $5 + WHERE id = $6`, + nullIfEmptyStr(accessEnc), nullIfEmptyStr(refreshEnc), expiresAt, normalizeScopes(scopes), now, id) + return err +} + +func (r *integrationRepository) SetConnectionStatus(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string) error { + now := time.Now().UTC() + _, err := r.db.Exec(ctx, ` + UPDATE integration_connections + SET status = $1, health = $2, health_detail = NULLIF($3, ''), health_checked_at = $4, updated_at = $4 + WHERE id = $5`, string(status), string(health), detail, now, id) + return err +} + +// --- OAuth state ------------------------------------------------------------ + +func (r *integrationRepository) CreateOAuthState(ctx context.Context, st *models.IntegrationOAuthState) error { + if st.ID == uuid.Nil { + st.ID = uuid.New() + } + st.CreatedAt = time.Now().UTC() + _, err := r.db.Exec(ctx, ` + INSERT INTO integration_oauth_states ( + id, organization_id, user_id, provider, state, code_verifier, + label, requested_scopes, expires_at, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + st.ID, st.OrganizationID, st.UserID, string(st.Provider), st.State, st.CodeVerifier, + st.Label, normalizeScopes(st.RequestedScopes), st.ExpiresAt, st.CreatedAt) + return err +} + +// TakeOAuthState atomically consumes a state: it returns the row only if it is +// unused and unexpired, marking it used in the same statement so a replayed +// callback can't be exchanged twice. +func (r *integrationRepository) TakeOAuthState(ctx context.Context, state string) (*models.IntegrationOAuthState, error) { + row := r.db.QueryRow(ctx, ` + UPDATE integration_oauth_states + SET used_at = NOW() + WHERE state = $1 AND used_at IS NULL AND expires_at > NOW() + RETURNING id, organization_id, user_id, provider, state, code_verifier, + label, requested_scopes, used_at, expires_at, created_at`, state) + var st models.IntegrationOAuthState + var provider string + err := row.Scan(&st.ID, &st.OrganizationID, &st.UserID, &provider, &st.State, &st.CodeVerifier, + &st.Label, &st.RequestedScopes, &st.UsedAt, &st.ExpiresAt, &st.CreatedAt) + if isNoRows(err) { + return nil, nil + } + if err != nil { + return nil, err + } + st.Provider = models.IntegrationProvider(provider) + return &st, nil +} + +// --- Event subscriptions ---------------------------------------------------- + +func (r *integrationRepository) CreateEventSubscription(ctx context.Context, sub *models.IntegrationEventSubscription) error { + if sub.ID == uuid.Nil { + sub.ID = uuid.New() + } + now := time.Now().UTC() + sub.CreatedAt = now + sub.UpdatedAt = now + cfg := sub.Config + if len(cfg) == 0 { + cfg = json.RawMessage("{}") + } + _, err := r.db.Exec(ctx, ` + INSERT INTO integration_event_subscriptions ( + id, connection_id, organization_id, event_type, action, config, enabled, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (connection_id, event_type, action) DO UPDATE SET + config = EXCLUDED.config, enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at`, + sub.ID, sub.ConnectionID, sub.OrganizationID, sub.EventType, string(sub.Action), cfg, sub.Enabled, now) + return err +} + +func (r *integrationRepository) ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) { + rows, err := r.db.Query(ctx, ` + SELECT id, connection_id, organization_id, event_type, action, config, enabled, created_at, updated_at + FROM integration_event_subscriptions + WHERE organization_id = $1 AND connection_id = $2 ORDER BY created_at DESC`, orgID, connID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.IntegrationEventSubscription{} + for rows.Next() { + s, err := scanEventSubscription(rows) + if err != nil { + return nil, err + } + out = append(out, *s) + } + return out, rows.Err() +} + +func (r *integrationRepository) DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error { + _, err := r.db.Exec(ctx, + `DELETE FROM integration_event_subscriptions WHERE organization_id = $1 AND id = $2`, orgID, id) + return err +} + +// MatchingDispatchTargets returns enabled subscriptions for an org+event whose +// connection is usable, each hydrated with the connection's encrypted secrets. +// Dispatch volume is per-event and low, so the secrets fetch is done per row. +func (r *integrationRepository) MatchingDispatchTargets(ctx context.Context, orgID uuid.UUID, eventType string) ([]DispatchTarget, error) { + rows, err := r.db.Query(ctx, ` + SELECT s.id, s.connection_id, s.organization_id, s.event_type, s.action, s.config, s.enabled, s.created_at, s.updated_at + FROM integration_event_subscriptions s + JOIN integration_connections c ON c.id = s.connection_id + WHERE s.organization_id = $1 AND s.event_type = $2 AND s.enabled + AND c.status IN ('connected', 'degraded')`, orgID, eventType) + if err != nil { + return nil, err + } + var subs []models.IntegrationEventSubscription + for rows.Next() { + s, err := scanEventSubscription(rows) + if err != nil { + rows.Close() + return nil, err + } + subs = append(subs, *s) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]DispatchTarget, 0, len(subs)) + for _, sub := range subs { + sec, err := r.GetConnectionSecrets(ctx, sub.ConnectionID) + if err != nil { + return nil, err + } + if sec == nil { + continue + } + out = append(out, DispatchTarget{Subscription: sub, Secrets: *sec}) + } + return out, nil +} + +// --- Sync runs -------------------------------------------------------------- + +func (r *integrationRepository) CreateSyncRun(ctx context.Context, run *models.IntegrationSyncRun) error { + if run.ID == uuid.Nil { + run.ID = uuid.New() + } + run.StartedAt = time.Now().UTC() + if run.Status == "" { + run.Status = "running" + } + _, err := r.db.Exec(ctx, ` + INSERT INTO integration_sync_runs (id, connection_id, organization_id, kind, status, detail, records_processed, started_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + run.ID, run.ConnectionID, run.OrganizationID, run.Kind, run.Status, run.Detail, run.RecordsProcessed, run.StartedAt) + return err +} + +func (r *integrationRepository) FinishSyncRun(ctx context.Context, id uuid.UUID, status, detail string, records int) error { + _, err := r.db.Exec(ctx, ` + UPDATE integration_sync_runs + SET status = $1, detail = $2, records_processed = $3, finished_at = NOW() + WHERE id = $4`, status, detail, records, id) + return err +} + +func (r *integrationRepository) ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := r.db.Query(ctx, ` + SELECT id, connection_id, organization_id, kind, status, detail, records_processed, started_at, finished_at + FROM integration_sync_runs + WHERE organization_id = $1 AND connection_id = $2 ORDER BY started_at DESC LIMIT $3`, orgID, connID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.IntegrationSyncRun{} + for rows.Next() { + var run models.IntegrationSyncRun + if err := rows.Scan(&run.ID, &run.ConnectionID, &run.OrganizationID, &run.Kind, &run.Status, + &run.Detail, &run.RecordsProcessed, &run.StartedAt, &run.FinishedAt); err != nil { + return nil, err + } + out = append(out, run) + } + return out, rows.Err() +} + +// --- scanning helpers ------------------------------------------------------- + type scanner interface { Scan(dest ...any) error } -func scanConnection(row scanner) (*models.IntegrationConnection, error) { - var c models.IntegrationConnection +func isNoRows(err error) bool { + return errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) +} + +func scanConnectionInto(row scanner, c *models.IntegrationConnection) error { var provider, status string if err := row.Scan( - &c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.DisplayFields, + &c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.AuthMethod, &c.DisplayFields, + &c.ConnectedByUserID, &c.ExternalAccountID, &c.ExternalAccountName, + &c.GrantedScopes, &c.TokenExpiresAt, &c.Health, &c.HealthDetail, &c.HealthCheckedAt, &c.LastSyncedAt, &c.LastError, &c.LastErrorAt, &c.CreatedAt, &c.UpdatedAt, ); err != nil { - return nil, err + return err } c.Provider = models.IntegrationProvider(provider) c.Status = models.IntegrationStatus(status) if len(c.DisplayFields) == 0 { c.DisplayFields = json.RawMessage("{}") } - return &c, nil + return nil } -// Meeting bookings +func scanConnectionSecretsInto(row scanner, sec *ConnectionSecrets) error { + var provider, status string + if err := row.Scan( + &sec.Conn.ID, &sec.Conn.OrganizationID, &provider, &sec.Conn.Label, &status, &sec.Conn.AuthMethod, &sec.Conn.DisplayFields, + &sec.Conn.ConnectedByUserID, &sec.Conn.ExternalAccountID, &sec.Conn.ExternalAccountName, + &sec.Conn.GrantedScopes, &sec.Conn.TokenExpiresAt, &sec.Conn.Health, &sec.Conn.HealthDetail, &sec.Conn.HealthCheckedAt, + &sec.Conn.LastSyncedAt, &sec.Conn.LastError, &sec.Conn.LastErrorAt, &sec.Conn.CreatedAt, &sec.Conn.UpdatedAt, + &sec.ConfigEncrypted, &sec.AccessTokenEnc, &sec.RefreshTokenEnc, + ); err != nil { + return err + } + sec.Conn.Provider = models.IntegrationProvider(provider) + sec.Conn.Status = models.IntegrationStatus(status) + if len(sec.Conn.DisplayFields) == 0 { + sec.Conn.DisplayFields = json.RawMessage("{}") + } + return nil +} + +func scanEventSubscription(row scanner) (*models.IntegrationEventSubscription, error) { + var s models.IntegrationEventSubscription + var action string + var cfg []byte + if err := row.Scan(&s.ID, &s.ConnectionID, &s.OrganizationID, &s.EventType, &action, &cfg, &s.Enabled, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + s.Action = models.IntegrationAction(action) + if len(cfg) == 0 { + cfg = []byte("{}") + } + s.Config = cfg + return &s, nil +} + +// --- Meeting bookings (unchanged behaviour) --------------------------------- func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error { if b.ID == uuid.Nil { @@ -223,12 +575,10 @@ func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *mod 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 - `, + 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, - ) + b.ContactID, b.CampaignID, raw) return err } @@ -240,11 +590,7 @@ func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID u 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) + FROM meeting_bookings WHERE organization_id = $1 ORDER BY created_at DESC LIMIT $2`, orgID, limit) if err != nil { return nil, err } @@ -253,11 +599,9 @@ func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID u out := []models.MeetingBooking{} for rows.Next() { var b models.MeetingBooking - if err := rows.Scan( - &b.ID, &b.OrganizationID, &b.Source, &b.ExternalEventID, + 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 { + &b.ContactID, &b.CampaignID, &b.CreatedAt); err != nil { return nil, err } out = append(out, b) diff --git a/web/src/app/app/admin/credentials/page.tsx b/web/src/app/app/admin/credentials/page.tsx index 84c935fa..5eb1dc68 100644 --- a/web/src/app/app/admin/credentials/page.tsx +++ b/web/src/app/app/admin/credentials/page.tsx @@ -158,8 +158,13 @@ function AWSCredsForm({ secret_access_key: "", }); const mut = useMutation({ - mutationFn: () => - initial ? updateAWSCredentials(initial.id, form) : createAWSCredentials(form), + mutationFn: async () => { + if (initial) { + await updateAWSCredentials(initial.id, form); + return { id: initial.id }; + } + return createAWSCredentials(form); + }, onSuccess: onSaved, }); diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx index b90645df..e3fb3e61 100644 --- a/web/src/app/app/audit/page.tsx +++ b/web/src/app/app/audit/page.tsx @@ -255,7 +255,7 @@ export default function AuditPage() { ); } -function Th({ children, className }: { children: React.ReactNode; className?: string }) { +function Th({ children, className }: { children?: React.ReactNode; className?: string }) { return ( = { - 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." }, + { + key: "api_token", + label: "Close API key", + type: "password", + required: true, + helper: "Settings → Developer → API Keys 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." }, + { + key: "api_token", + label: "Warmbly API key", + type: "password", + required: true, + helper: "Create a scoped key under Settings → API keys, then paste it into Zapier.", + }, ], 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." }, + { + key: "api_token", + label: "Warmbly API key", + type: "password", + required: true, + helper: "Create a scoped key under Settings → API keys, then paste it into Make.", + }, ], 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." }, + { + key: "api_token", + label: "Warmbly API key", + type: "password", + required: true, + helper: "Create a scoped key under Settings → API keys, then paste it into n8n.", + }, ], 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." }, + { + 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." }, + { + key: "sheet_id", + label: "Sheet ID", + placeholder: "1AbC…XyZ", + helper: "The long ID in the sheet URL between /d/ and /edit. Optional — set per automation later.", + }, ], }; @@ -98,24 +123,46 @@ export default function ConnectDrawer({ }) { const [label, setLabel] = React.useState(""); const [config, setConfig] = React.useState>({}); - const connect = useConnectIntegration(); + const [step, setStep] = React.useState<"overview" | "credentials">("overview"); + const [busy, setBusy] = React.useState(false); + const connect = useConnectIntegration(); + const startOAuth = useStartIntegrationOAuth(); + const finishOAuth = useFinishIntegrationOAuth(); + + const isOAuth = entry.auth_method === "oauth"; + const isInbound = entry.provider === "calendly" || entry.provider === "cal_com"; 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(); + async function runOAuth() { + setBusy(true); + try { + const { url } = await startOAuth.mutateAsync({ provider: entry.provider, label: label.trim() }); + const { code, state } = await openOAuthPopup(url); + const conn = await finishOAuth.mutateAsync({ code, state }); + toast.success(`Connected to ${entry.name}`); + onConnected(conn); + onClose(); + } catch (err: unknown) { + toast.error(errMessage(err) ?? "Connection failed"); + } finally { + setBusy(false); + } + } + async function submitCredentials(e: React.FormEvent) { + e.preventDefault(); for (const f of fields) { if (f.required && !config[f.key]?.trim()) { toast.error(`${f.label} is required`); return; } } - + setBusy(true); try { const conn = await connect.mutateAsync({ provider: entry.provider, @@ -126,11 +173,162 @@ export default function ConnectDrawer({ 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"); + toast.error(errMessage(err) ?? "Connect failed"); + } finally { + setBusy(false); } } + const notConfigured = isOAuth && !entry.configured; + + return ( + + {step === "overview" && ( +
+
+

{entry.tagline}

+ + {entry.highlights && entry.highlights.length > 0 && ( +
+ What you get +
    + {entry.highlights.map((h) => ( +
  • + + {h} +
  • + ))} +
+
+ )} + + {isOAuth && entry.scopes && entry.scopes.length > 0 && ( +
+ Permissions requested +
+ {entry.scopes.map((s) => ( +
+ + {s} +
+ ))} +
+

+ + Tokens are encrypted at rest with your organization key. We never store your password. +

+
+ )} + +
+ + +

+ Useful if you connect more than one {entry.name} account. +

+
+ + {notConfigured && ( +
+

+ {entry.name} OAuth isn’t enabled on this workspace yet. An admin needs to add the{" "} + {entry.name} app credentials. Reach out and we’ll switch it on. +

+
+ )} +
+ + + {isOAuth ? ( + + ) : isInbound ? ( + + ) : ( + + )} + +
+ )} + + {step === "credentials" && ( +
+
+ {fields.map((f) => ( +
+ + update(f.key, v)} + placeholder={f.placeholder} + className="font-mono" + /> + {f.helper && ( +

{f.helper}

+ )} +
+ ))} + {entry.docs_url && ( + + + {entry.name} docs + + )} +
+ setStep("overview")} cancelLabel="Back"> + + +
+ )} +
+ ); +} + +// --- shared drawer chrome (also used by ConnectionDetail) ------------------- + +export function Drawer({ + title, + name, + provider, + onClose, + children, +}: { + title: string; + name: string; + provider: string; + onClose: () => void; + children: React.ReactNode; +}) { return (
- -
-
-

{entry.tagline}

- -
- - 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" - /> -

- Useful if you connect more than one of the same provider. -

-
- - {fields.map((f) => ( -
- - 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 && ( -

{f.helper}

- )} -
- ))} - - {fields.length === 0 && ( -
-

- {entry.webhook_hint ?? "We will mint a URL for you on the next screen. Paste it into the provider's webhook configuration."} -

-
- )} -
- -
- - -
-
+ {children} ); } + +export function DrawerFooter({ + onClose, + cancelLabel = "Cancel", + children, +}: { + onClose: () => void; + cancelLabel?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +export function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export const primaryBtn = + "h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"; + +function errMessage(err: unknown): string | undefined { + const e = err as { response?: { data?: { message?: string; error?: string } }; message?: string }; + return e.response?.data?.message ?? e.response?.data?.error ?? e.message; +} diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx new file mode 100644 index 00000000..0bacfb1e --- /dev/null +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -0,0 +1,513 @@ +// Connection-management drawer — this is HOW a user actually uses an +// integration after connecting. It surfaces lifecycle + health, the connected +// account, granted scopes, recent activity (sync runs), and the automation +// rules wired to this connection: +// +// "When a prospect replies (positive, ≥60% confidence) → notify #sales +// with: 🔥 {{contact_email}} is interested — {{subject}}" +// +// Each rule is fully customizable: trigger event, filters (reply intent + min +// confidence), destination (Slack channel / Sheet ID / webhook), and a custom +// message template with {{placeholder}} substitution. Reauthorize / disconnect +// live here too. + +"use client"; + +import React from "react"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + Loader2Icon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, + UnplugIcon, + ZapIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { Label, TextInput } from "@/components/ui/field"; +import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail"; +import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration"; +import { + useFinishIntegrationOAuth, + useReauthIntegration, +} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth"; +import { + useCreateConnectionEvent, + useDeleteConnectionEvent, +} from "@/lib/api/hooks/app/integrations/useConnectionEvents"; +import { openOAuthPopup } from "@/lib/integrations/oauthPopup"; +import { + EVENT_LABELS, + REPLY_INTENT_OPTIONS, + type IntegrationCatalogEntry, + type IntegrationConnection, + type IntegrationEventSubscription, +} from "@/lib/api/models/app/integrations/Integration"; +import { cn } from "@/lib/utils"; + +import { Drawer, SectionLabel } from "./ConnectDrawer"; +import StatusPill, { HealthDot } from "./StatusPill"; + +const REPLY_EVENT = "campaign.reply_received"; + +export default function ConnectionDetail({ + connection, + entry, + onClose, +}: { + connection: IntegrationConnection; + entry?: IntegrationCatalogEntry; + onClose: () => void; +}) { + const detail = useConnectionDetail(connection.id); + const disconnect = useDisconnectIntegration(); + const reauth = useReauthIntegration(); + const finishOAuth = useFinishIntegrationOAuth(); + const createEvent = useCreateConnectionEvent(); + const deleteEvent = useDeleteConnectionEvent(); + + const [adding, setAdding] = React.useState(false); + const [busy, setBusy] = React.useState(false); + + const conn = detail.data?.connection ?? connection; + const events = detail.data?.events ?? []; + const runs = detail.data?.runs ?? []; + + const availableEvents = entry?.events ?? Object.keys(EVENT_LABELS); + const isOAuth = conn.auth_method === "oauth"; + const needsReauth = conn.status === "reauth_required"; + + async function handleReauth() { + setBusy(true); + try { + const { url } = await reauth.mutateAsync(conn.id); + const { code, state } = await openOAuthPopup(url); + await finishOAuth.mutateAsync({ code, state }); + toast.success("Reconnected"); + detail.refetch(); + } catch (err: unknown) { + toast.error(msg(err) ?? "Reconnect failed"); + } finally { + setBusy(false); + } + } + + async function handleDisconnect() { + if (!window.confirm(`Disconnect ${conn.label}? Automations using it will stop.`)) return; + try { + await disconnect.mutateAsync(conn.id); + toast.success("Disconnected"); + onClose(); + } catch { + toast.error("Disconnect failed"); + } + } + + async function addAutomation(eventType: string, config: Record) { + try { + await createEvent.mutateAsync({ + connectionId: conn.id, + event_type: eventType, + action: actionForProvider(conn.provider), + config, + enabled: true, + }); + toast.success("Automation added"); + setAdding(false); + detail.refetch(); + } catch (err: unknown) { + toast.error(msg(err) ?? "Could not add automation"); + } + } + + return ( + +
+ {/* Status header */} +
+
+ +
+ + {conn.health} +
+
+ {conn.external_account_name && } + + + {conn.last_error && ( +
+ +

{conn.last_error}

+
+ )} + {needsReauth && ( + + )} +
+ + {/* Granted access */} + {conn.granted_scopes && conn.granted_scopes.length > 0 && ( +
+ Granted access +
+ {conn.granted_scopes.map((s) => ( + + {s} + + ))} +
+
+ )} + + {/* Automations — the core "how you use it" surface */} +
+
+ Automations + {!adding && ( + + )} +
+ + {events.length === 0 && !adding && ( +

+ No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label} — + e.g. ping a channel when a prospect replies. +

+ )} + + {events.map((ev) => ( + + deleteEvent + .mutateAsync({ connectionId: conn.id, eventId: ev.id }) + .then(() => detail.refetch()) + } + /> + ))} + + {adding && ( + setAdding(false)} + onAdd={addAutomation} + busy={createEvent.isPending} + /> + )} +
+ + {/* Activity */} +
+ Recent activity + {runs.length === 0 ? ( +

Nothing yet.

+ ) : ( +
+ {runs.map((r) => ( +
+ {r.status === "success" ? ( + + ) : r.status === "error" ? ( + + ) : ( + + )} + + {r.kind} + {r.detail ? ` · ${r.detail}` : ""} + + + {new Date(r.started_at).toLocaleTimeString()} + +
+ ))} +
+ )} +
+
+ +
+ + {isOAuth && !needsReauth && ( + + )} +
+
+ ); +} + +// AutomationRow renders one configured rule with a human summary of its +// trigger, filters, destination, and custom message. +function AutomationRow({ sub, onDelete }: { sub: IntegrationEventSubscription; onDelete: () => void }) { + const cfg = (sub.config ?? {}) as Record; + const intents = Array.isArray(cfg.intents) ? (cfg.intents as string[]) : []; + const minConf = typeof cfg.min_confidence === "number" ? cfg.min_confidence : undefined; + const dest = + (cfg.channel as string) || (cfg.sheet_id as string) || (cfg.url as string) || (cfg.webhook_url as string) || ""; + const tmpl = (cfg.message_template as string) || ""; + + const filters: string[] = []; + if (intents.length) filters.push(intents.join(", ")); + if (minConf) filters.push(`≥${Math.round(minConf * 100)}%`); + + return ( +
+
+ +
+
+ {EVENT_LABELS[sub.event_type] ?? sub.event_type} +
+
+ {dest || sub.action} + {filters.length > 0 && ` · ${filters.join(" · ")}`} +
+
+ +
+ {tmpl && ( +

“{tmpl}”

+ )} +
+ ); +} + +// AddAutomation is the rule builder. Everything a user needs to customize a +// behaviour lives here: trigger, reply-intent + confidence filters, the +// destination, and a custom message template. +function AddAutomation({ + provider, + availableEvents, + onAdd, + onCancel, + busy, +}: { + provider: string; + availableEvents: string[]; + onAdd: (eventType: string, config: Record) => void; + onCancel: () => void; + busy: boolean; +}) { + const [eventType, setEventType] = React.useState( + availableEvents.includes(REPLY_EVENT) ? REPLY_EVENT : availableEvents[0] ?? REPLY_EVENT, + ); + const [dest, setDest] = React.useState(""); + const [intents, setIntents] = React.useState([]); + const [minConf, setMinConf] = React.useState(0); + const [template, setTemplate] = React.useState(""); + + const needsChannel = provider === "slack"; + const needsSheet = provider === "google_sheets"; + const needsURL = provider !== "slack" && provider !== "google_sheets" && provider !== "discord" && + provider !== "hubspot" && provider !== "pipedrive"; + const isReplyTrigger = eventType === REPLY_EVENT; + const destRequired = needsChannel || needsURL; + + function toggleIntent(v: string) { + setIntents((cur) => (cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v])); + } + + function buildConfig(): Record { + const cfg: Record = {}; + if (needsChannel && dest.trim()) cfg.channel = dest.trim(); + if (needsSheet && dest.trim()) cfg.sheet_id = dest.trim(); + if (needsURL && dest.trim()) cfg.url = dest.trim(); + if (isReplyTrigger && intents.length) cfg.intents = intents; + if (isReplyTrigger && minConf > 0) cfg.min_confidence = minConf; + if (template.trim()) cfg.message_template = template.trim(); + return cfg; + } + + const destLabel = needsChannel ? "Channel" : needsSheet ? "Sheet ID (optional)" : "Destination URL"; + const destPlaceholder = needsChannel ? "#sales" : needsSheet ? "1AbC…XyZ" : "https://…"; + const canSubmit = !busy && !(destRequired && !dest.trim()); + + return ( +
+
+ + +
+ + {/* Reply-only filters */} + {isReplyTrigger && ( +
+ +
+ {REPLY_INTENT_OPTIONS.map((opt) => { + const on = intents.includes(opt.value); + return ( + + ); + })} +
+
+ + setMinConf(Number(e.target.value) / 100)} + className="w-full accent-sky-600" + /> +
+
+ )} + + {(needsChannel || needsSheet || needsURL) && ( +
+ + +
+ )} + + {/* Custom message (for notification-style actions) */} + {(needsChannel || provider === "discord" || needsURL) && ( +
+ +