feat: add integration oauth connections

Adds OAuth-backed integration connection management across the API, repository, event dispatch, migrations, docs, and dashboard UI.

Includes realtime invalidation and small dashboard type compatibility fixes needed for the web typecheck gate.
This commit is contained in:
Matthew Meszaros
2026-06-01 04:25:14 +02:00
parent f93a01e966
commit 4500aeba0f
42 changed files with 4361 additions and 612 deletions
+12 -1
View File
@@ -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,
+17 -1
View File
@@ -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
+149
View File
@@ -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 `<PROVIDER>_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
```
<INTEGRATIONS_OAUTH_REDIRECT_URL>
# 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.
+340 -67
View File
@@ -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 <script>.
blob, _ := json.Marshal(payload)
html := `<!doctype html><html><head><meta charset="utf-8"><title>Connecting…</title></head>
<body style="font-family:system-ui;background:#f8fafc;color:#0f172a;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<div style="text-align:center">
<p style="font-size:14px">Finishing connection… you can close this window.</p>
</div>
<script>
(function(){
var msg = ` + string(blob) + `;
try { if (window.opener) { window.opener.postMessage(msg, "*"); } } catch (e) {}
setTimeout(function(){ window.close(); }, 300);
})();
</script>
</body></html>`
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)
}
+20
View File
@@ -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)
}
+36
View File
@@ -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)
}
+59
View File
@@ -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
}
+261
View File
@@ -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
}
+79 -36
View File
@@ -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,
},
}
}
+338
View File
@@ -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]
}
+371
View File
@@ -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 <PREFIX>_OAUTH_CLIENT_ID / <PREFIX>_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)
}
+450 -82
View File
@@ -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
}
+31
View File
@@ -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
@@ -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;
@@ -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);
+1
View File
@@ -49,6 +49,7 @@ const (
AuditEntityAWSCredentials AuditEntityType = "aws_credentials"
AuditEntityWorkerProfile AuditEntityType = "worker_profile"
AuditEntityRelease AuditEntityType = "release"
AuditEntityIntegration AuditEntityType = "integration"
)
type AuditLog struct {
+161 -19
View File
@@ -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"`
+417 -73
View File
@@ -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)
+7 -2
View File
@@ -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,
});
+1 -1
View File
@@ -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 (
<th
className={`px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] ${className ?? ""}`}
@@ -1,24 +1,49 @@
// Drawer that handles per-provider connect inputs. Each provider has a
// slightly different set of required fields:
// - webhook providers (Calendly, Cal.com): no fields, just a label
// - oauth providers (HubSpot, Salesforce, Pipedrive, Google Sheets,
// Slack): launch OAuth (we accept a pasted token until OAuth lands)
// - api-key providers (Close, Zapier, Make, n8n): paste a token
// - webhook-url providers (Discord): paste the channel webhook URL
// Multi-step connect drawer. The connect path depends on the provider's
// auth method:
// - oauth (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce): one-click
// "Connect with X" → provider popup → encrypted tokens stored server-side.
// No credentials are ever pasted. Providers without server credentials
// render as "coming soon".
// - api_key (Close, Zapier, Make, n8n): paste a provider token (Close) or a
// scoped Warmbly API key (Zapier/Make/n8n).
// - webhook (Discord): paste a channel webhook URL.
// - inbound webhook (Calendly, Cal.com): we mint an inbound URL on connect.
//
// Step 1 is always an honest overview — what you get, which scopes are
// requested. Step 2 is the connect action. Step 3 confirms + points to the
// detail drawer where event automations are configured.
"use client";
import React from "react";
import { XIcon } from "lucide-react";
import {
ArrowRightIcon,
CheckCircle2Icon,
ExternalLinkIcon,
KeyRoundIcon,
Loader2Icon,
LockIcon,
ShieldCheckIcon,
XIcon,
ZapIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { Label, TextInput } from "@/components/ui/field";
import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration";
import {
useFinishIntegrationOAuth,
useStartIntegrationOAuth,
} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth";
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
import type {
IntegrationCatalogEntry,
IntegrationConnection,
} from "@/lib/api/models/app/integrations/Integration";
import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration";
import { cn } from "@/lib/utils";
import ProviderGlyph from "./ProviderGlyph";
interface FieldDef {
key: string;
label: string;
@@ -28,62 +53,62 @@ interface FieldDef {
required?: boolean;
}
// Credential fields for non-OAuth providers only. OAuth providers never paste.
const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = {
calendly: [],
cal_com: [],
hubspot: [
{ key: "workspace", label: "Workspace name", placeholder: "Acme" },
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
helper: "Paste a token with crm.objects.contacts read/write scope." },
],
salesforce: [
{ key: "workspace", label: "Org name", placeholder: "Acme Salesforce" },
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
helper: "Paste a Salesforce session ID or OAuth bearer." },
],
pipedrive: [
{ key: "workspace", label: "Company name", placeholder: "Acme" },
{ key: "api_token", label: "API token", type: "password", required: true,
helper: "Settings → Personal → API in your Pipedrive account." },
],
close: [
{ key: "workspace", label: "Organization", placeholder: "Acme" },
{ key: "api_token", label: "API key", type: "password", required: true,
helper: "Settings → Developer in Close." },
{
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<Record<string, string>>({});
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 (
<Drawer title="Connect" name={entry.name} provider={entry.provider} onClose={onClose}>
{step === "overview" && (
<div className="flex-1 overflow-auto flex flex-col">
<div className="px-5 py-5 space-y-5">
<p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p>
{entry.highlights && entry.highlights.length > 0 && (
<div className="space-y-2">
<SectionLabel>What you get</SectionLabel>
<ul className="space-y-1.5">
{entry.highlights.map((h) => (
<li key={h} className="flex items-start gap-2 text-[12.5px] text-slate-700">
<CheckCircle2Icon className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
<span>{h}</span>
</li>
))}
</ul>
</div>
)}
{isOAuth && entry.scopes && entry.scopes.length > 0 && (
<div className="space-y-2">
<SectionLabel>Permissions requested</SectionLabel>
<div className="rounded-md border border-slate-200 bg-slate-50/70 divide-y divide-slate-200">
{entry.scopes.map((s) => (
<div key={s} className="flex items-center gap-2 px-2.5 py-1.5">
<ShieldCheckIcon className="w-3 h-3 text-slate-400 shrink-0" />
<code className="text-[10.5px] text-slate-600 font-mono truncate">{s}</code>
</div>
))}
</div>
<p className="text-[10.5px] text-slate-400 leading-relaxed flex items-center gap-1">
<LockIcon className="w-3 h-3" />
Tokens are encrypted at rest with your organization key. We never store your password.
</p>
</div>
)}
<div>
<Label>Connection label (optional)</Label>
<TextInput value={label} onChange={setLabel} placeholder={entry.name} />
<p className="text-[10.5px] text-slate-400 mt-1">
Useful if you connect more than one {entry.name} account.
</p>
</div>
{notConfigured && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5">
<p className="text-[12px] text-amber-800 leading-relaxed">
{entry.name} OAuth isnt enabled on this workspace yet. An admin needs to add the{" "}
{entry.name} app credentials. Reach out and well switch it on.
</p>
</div>
)}
</div>
<DrawerFooter onClose={onClose}>
{isOAuth ? (
<button
type="button"
disabled={busy || notConfigured}
onClick={runOAuth}
className={cn(primaryBtn, (busy || notConfigured) && "opacity-60 cursor-not-allowed")}
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ZapIcon className="w-3.5 h-3.5" />}
{busy ? "Connecting…" : `Connect with ${entry.name}`}
</button>
) : isInbound ? (
<button
type="button"
disabled={busy}
onClick={() => void submitCredentials(new Event("submit") as unknown as React.FormEvent)}
className={cn(primaryBtn, busy && "opacity-60")}
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ArrowRightIcon className="w-3.5 h-3.5" />}
{busy ? "Creating…" : "Create inbound URL"}
</button>
) : (
<button type="button" onClick={() => setStep("credentials")} className={primaryBtn}>
<KeyRoundIcon className="w-3.5 h-3.5" />
Continue
</button>
)}
</DrawerFooter>
</div>
)}
{step === "credentials" && (
<form onSubmit={submitCredentials} className="flex-1 overflow-auto flex flex-col">
<div className="px-5 py-5 space-y-4">
{fields.map((f) => (
<div key={f.key}>
<Label>
{f.label}
{f.required && <span className="text-rose-500 ml-0.5">*</span>}
</Label>
<TextInput
type={f.type ?? "text"}
value={config[f.key] ?? ""}
onChange={(v) => update(f.key, v)}
placeholder={f.placeholder}
className="font-mono"
/>
{f.helper && (
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p>
)}
</div>
))}
{entry.docs_url && (
<a
href={entry.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-sky-700 hover:underline"
>
<ExternalLinkIcon className="w-3 h-3" />
{entry.name} docs
</a>
)}
</div>
<DrawerFooter onClose={() => setStep("overview")} cancelLabel="Back">
<button type="submit" disabled={busy} className={cn(primaryBtn, busy && "opacity-60")}>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle2Icon className="w-3.5 h-3.5" />}
{busy ? "Connecting…" : "Connect"}
</button>
</DrawerFooter>
</form>
)}
</Drawer>
);
}
// --- 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 (
<div className="fixed inset-0 z-40 flex">
<button
@@ -139,14 +337,12 @@ export default function ConnectDrawer({
onClick={onClose}
className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"
/>
<div className="ml-auto h-full w-[480px] bg-white shadow-xl flex flex-col z-10 relative">
<div className="ml-auto h-full w-[480px] max-w-[92vw] bg-white shadow-xl flex flex-col z-10 relative animate-[slidein_.18s_ease-out]">
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
<div className="w-7 h-7 rounded bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[12px] font-semibold uppercase">
{entry.name.charAt(0)}
</div>
<ProviderGlyph provider={provider} name={name} size={7} />
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div>
<div className="text-[12.5px] text-slate-900 font-medium truncate">{entry.name}</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{title}</div>
<div className="text-[12.5px] text-slate-900 font-medium truncate">{name}</div>
</div>
<button
type="button"
@@ -157,75 +353,45 @@ export default function ConnectDrawer({
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
<form onSubmit={submit} className="flex-1 overflow-auto flex flex-col">
<div className="px-5 py-5 space-y-4">
<p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p>
<div>
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
Label (optional)
</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={entry.name}
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 transition-colors"
/>
<p className="text-[10.5px] text-slate-400 mt-1">
Useful if you connect more than one of the same provider.
</p>
</div>
{fields.map((f) => (
<div key={f.key}>
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
{f.label}
{f.required && <span className="text-rose-500 ml-0.5">*</span>}
</label>
<input
type={f.type ?? "text"}
value={config[f.key] ?? ""}
onChange={(e) => update(f.key, e.target.value)}
placeholder={f.placeholder}
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors"
/>
{f.helper && (
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p>
)}
</div>
))}
{fields.length === 0 && (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2.5">
<p className="text-[12px] text-slate-600 leading-relaxed">
{entry.webhook_hint ?? "We will mint a URL for you on the next screen. Paste it into the provider's webhook configuration."}
</p>
</div>
)}
</div>
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="h-7 px-3 rounded border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={connect.isPending}
className={cn(
"h-7 px-3 rounded text-[12px] font-medium text-white transition-colors",
connect.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700",
)}
>
{connect.isPending ? "Connecting…" : "Connect"}
</button>
</div>
</form>
{children}
</div>
</div>
);
}
export function DrawerFooter({
onClose,
cancelLabel = "Cancel",
children,
}: {
onClose: () => void;
cancelLabel?: string;
children: React.ReactNode;
}) {
return (
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2 shrink-0">
<button
type="button"
onClick={onClose}
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
>
{cancelLabel}
</button>
{children}
</div>
);
}
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{children}</div>
);
}
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;
}
@@ -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<string, unknown>) {
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 (
<Drawer title="Manage" name={conn.label} provider={conn.provider} onClose={onClose}>
<div className="flex-1 overflow-auto">
{/* Status header */}
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between gap-2">
<StatusPill status={conn.status} />
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
<HealthDot health={conn.health} />
{conn.health}
</div>
</div>
{conn.external_account_name && <Row label="Account" value={conn.external_account_name} />}
<Row label="Auth" value={conn.auth_method.replace("_", " ")} mono />
<Row
label="Last sync"
value={conn.last_synced_at ? new Date(conn.last_synced_at).toLocaleString() : "never"}
/>
{conn.last_error && (
<div className="rounded-md border border-rose-200 bg-rose-50 px-2.5 py-2 flex items-start gap-2">
<AlertTriangleIcon className="w-3.5 h-3.5 text-rose-500 mt-0.5 shrink-0" />
<p className="text-[11px] text-rose-700 leading-relaxed break-words">{conn.last_error}</p>
</div>
)}
{needsReauth && (
<button
type="button"
onClick={handleReauth}
disabled={busy}
className="w-full h-8 rounded-md bg-amber-500 hover:bg-amber-600 text-white text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors"
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <RefreshCwIcon className="w-3.5 h-3.5" />}
Reconnect to fix
</button>
)}
</div>
{/* Granted access */}
{conn.granted_scopes && conn.granted_scopes.length > 0 && (
<div className="px-5 py-4 border-b border-slate-200 space-y-2">
<SectionLabel>Granted access</SectionLabel>
<div className="flex flex-wrap gap-1">
{conn.granted_scopes.map((s) => (
<span
key={s}
className="px-1.5 h-5 inline-flex items-center rounded bg-slate-100 text-[10px] font-mono text-slate-600"
>
{s}
</span>
))}
</div>
</div>
)}
{/* Automations — the core "how you use it" surface */}
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between">
<SectionLabel>Automations</SectionLabel>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
className="h-6 px-2 rounded text-[11px] text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
New rule
</button>
)}
</div>
{events.length === 0 && !adding && (
<p className="text-[11.5px] text-slate-400 leading-relaxed">
No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label}
e.g. ping a channel when a prospect replies.
</p>
)}
{events.map((ev) => (
<AutomationRow
key={ev.id}
sub={ev}
onDelete={() =>
deleteEvent
.mutateAsync({ connectionId: conn.id, eventId: ev.id })
.then(() => detail.refetch())
}
/>
))}
{adding && (
<AddAutomation
provider={conn.provider}
availableEvents={availableEvents}
onCancel={() => setAdding(false)}
onAdd={addAutomation}
busy={createEvent.isPending}
/>
)}
</div>
{/* Activity */}
<div className="px-5 py-4 space-y-2">
<SectionLabel>Recent activity</SectionLabel>
{runs.length === 0 ? (
<p className="text-[11.5px] text-slate-400">Nothing yet.</p>
) : (
<div className="space-y-1">
{runs.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-[11px]">
{r.status === "success" ? (
<CheckCircle2Icon className="w-3 h-3 text-emerald-500 shrink-0" />
) : r.status === "error" ? (
<AlertTriangleIcon className="w-3 h-3 text-rose-500 shrink-0" />
) : (
<Loader2Icon className="w-3 h-3 text-slate-400 animate-spin shrink-0" />
)}
<span className="text-slate-600 truncate flex-1">
{r.kind}
{r.detail ? ` · ${r.detail}` : ""}
</span>
<span className="text-slate-400 tabular-nums shrink-0">
{new Date(r.started_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-between shrink-0">
<button
type="button"
onClick={handleDisconnect}
className="h-7 px-3 rounded-md text-[12px] text-rose-600 hover:bg-rose-50 inline-flex items-center gap-1.5 transition-colors"
>
<UnplugIcon className="w-3.5 h-3.5" />
Disconnect
</button>
{isOAuth && !needsReauth && (
<button
type="button"
onClick={handleReauth}
disabled={busy}
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 inline-flex items-center gap-1.5 transition-colors"
>
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <RefreshCwIcon className="w-3.5 h-3.5" />}
Reauthorize
</button>
)}
</div>
</Drawer>
);
}
// 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<string, unknown>;
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 (
<div className="rounded-md border border-slate-200 px-2.5 py-2">
<div className="flex items-center gap-2">
<ZapIcon className="w-3.5 h-3.5 text-sky-500 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-[12px] text-slate-800 truncate">
{EVENT_LABELS[sub.event_type] ?? sub.event_type}
</div>
<div className="text-[10px] text-slate-400 font-mono truncate">
{dest || sub.action}
{filters.length > 0 && ` · ${filters.join(" · ")}`}
</div>
</div>
<button
type="button"
onClick={onDelete}
aria-label="Remove automation"
className="h-6 w-6 rounded text-slate-400 hover:text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center transition-colors"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
</div>
{tmpl && (
<p className="mt-1.5 pl-5 text-[10.5px] text-slate-500 italic truncate">{tmpl}</p>
)}
</div>
);
}
// 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<string, unknown>) => 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<string[]>([]);
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<string, unknown> {
const cfg: Record<string, unknown> = {};
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 (
<div className="rounded-md border border-sky-200 bg-sky-50/40 p-3 space-y-3">
<div>
<Label>When this happens</Label>
<select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
>
{availableEvents.map((ev) => (
<option key={ev} value={ev}>
{EVENT_LABELS[ev] ?? ev}
</option>
))}
</select>
</div>
{/* Reply-only filters */}
{isReplyTrigger && (
<div className="space-y-2">
<Label>Only for these reply types (optional)</Label>
<div className="flex flex-wrap gap-1">
{REPLY_INTENT_OPTIONS.map((opt) => {
const on = intents.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => toggleIntent(opt.value)}
className={cn(
"h-6 px-2 rounded-full text-[10.5px] border transition-colors",
on
? "bg-sky-600 border-sky-600 text-white"
: "bg-white border-slate-200 text-slate-600 hover:border-slate-300",
)}
>
{opt.label}
</button>
);
})}
</div>
<div>
<Label>Minimum confidence: {Math.round(minConf * 100)}%</Label>
<input
type="range"
min={0}
max={100}
step={5}
value={Math.round(minConf * 100)}
onChange={(e) => setMinConf(Number(e.target.value) / 100)}
className="w-full accent-sky-600"
/>
</div>
</div>
)}
{(needsChannel || needsSheet || needsURL) && (
<div>
<Label>{destLabel}</Label>
<TextInput
value={dest}
onChange={setDest}
placeholder={destPlaceholder}
className={needsSheet || needsURL ? "font-mono" : undefined}
/>
</div>
)}
{/* Custom message (for notification-style actions) */}
{(needsChannel || provider === "discord" || needsURL) && (
<div>
<Label>Custom message (optional)</Label>
<textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
rows={2}
placeholder="🔥 {{contact_email}} replied — {{subject}}"
className="w-full px-2 py-1.5 rounded-md border border-slate-200 bg-white text-[12px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 resize-none"
/>
<p className="text-[10px] text-slate-400 mt-1">
Use {"{{contact_email}}"}, {"{{subject}}"}, {"{{intent}}"}, {"{{campaign_id}}"}, {"{{reason}}"}.
</p>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-0.5">
<button
type="button"
onClick={onCancel}
className="h-6 px-2.5 rounded text-[11.5px] text-slate-600 hover:text-slate-900"
>
Cancel
</button>
<button
type="button"
disabled={!canSubmit}
onClick={() => onAdd(eventType, buildConfig())}
className={cn(
"h-6 px-2.5 rounded text-[11.5px] font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors",
!canSubmit && "opacity-60",
)}
>
{busy ? "Adding…" : "Add automation"}
</button>
</div>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{label}</span>
<span className={cn("text-[12px] text-slate-700 truncate", mono && "font-mono")}>{value}</span>
</div>
);
}
// actionForProvider maps a connection's provider to the action its automations
// perform. Mirrors defaultActionForProvider in the model module.
function actionForProvider(provider: string): string {
switch (provider) {
case "slack":
return "slack.notify";
case "discord":
return "discord.notify";
case "hubspot":
return "hubspot.upsert_contact";
case "pipedrive":
return "pipedrive.upsert_person";
case "google_sheets":
return "google_sheets.append_row";
default:
return "webhook.ping";
}
}
function msg(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;
}
@@ -0,0 +1,46 @@
// A small branded glyph for each provider. We don't ship third-party logo
// assets, so we render the provider initial on a per-brand tinted tile —
// distinct enough to scan a directory quickly while staying on-theme.
import { cn } from "@/lib/utils";
const BRAND: Record<string, { bg: string; ring: string; text: string }> = {
hubspot: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" },
salesforce: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" },
pipedrive: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-700" },
close: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" },
zapier: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" },
make: { bg: "bg-violet-50", ring: "ring-violet-200", text: "text-violet-600" },
n8n: { bg: "bg-rose-50", ring: "ring-rose-200", text: "text-rose-600" },
slack: { bg: "bg-fuchsia-50", ring: "ring-fuchsia-200", text: "text-fuchsia-600" },
discord: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" },
calendly: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" },
cal_com: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-800" },
google_sheets: { bg: "bg-emerald-50", ring: "ring-emerald-200", text: "text-emerald-600" },
};
export default function ProviderGlyph({
provider,
name,
size = 9,
}: {
provider: string;
name: string;
size?: 7 | 9 | 10;
}) {
const brand = BRAND[provider] ?? { bg: "bg-sky-50", ring: "ring-sky-100", text: "text-sky-700" };
const dim = size === 7 ? "w-7 h-7 text-[12px]" : size === 10 ? "w-10 h-10 text-[15px]" : "w-9 h-9 text-[13px]";
return (
<div
className={cn(
"rounded-md ring-1 inline-flex items-center justify-center font-semibold uppercase shrink-0",
dim,
brand.bg,
brand.ring,
brand.text,
)}
>
{name.charAt(0)}
</div>
);
}
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
import type { IntegrationHealth, IntegrationStatus } from "@/lib/api/models/app/integrations/Integration";
const TONES: Record<string, { bg: string; text: string; dot: string; label: string }> = {
connected: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500", label: "connected" },
authorizing: { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500", label: "authorizing" },
pending: { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500", label: "pending" },
degraded: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500", label: "degraded" },
reauth_required: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500", label: "reconnect" },
disconnected: { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400", label: "not connected" },
};
export default function StatusPill({ status }: { status: IntegrationStatus | string }) {
const tone = TONES[status] ?? TONES.disconnected;
return (
<span
className={cn(
"inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium",
tone.bg,
tone.text,
)}
>
<span className={cn("size-1.5 rounded-full", tone.dot)} />
{tone.label}
</span>
);
}
const HEALTH_DOT: Record<string, string> = {
healthy: "bg-emerald-500",
degraded: "bg-amber-500",
down: "bg-rose-500",
unknown: "bg-slate-300",
};
export function HealthDot({ health }: { health: IntegrationHealth | string }) {
return <span className={cn("size-1.5 rounded-full", HEALTH_DOT[health] ?? HEALTH_DOT.unknown)} />;
}
+187 -189
View File
@@ -1,26 +1,15 @@
// Integrations dashboard.
// Integrations marketplace.
//
// One page covers the integration surface: catalog of available providers
// (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack,
// Discord, Calendly, Cal.com, Google Sheets), per-org connection state,
// inbound webhook URLs, and meeting bookings.
//
// Layout follows the Page primitives: stat strip across the top, section
// bars between zones, no max-width chrome. Connect / disconnect happens
// in an inline drawer so the page stays a single navigation target from
// the sidebar.
// The connect experience is the point of this page: a searchable app directory
// grouped by category, a "connected" rail across the top, a multi-step connect
// drawer (one-click OAuth where the provider supports it), and a management
// drawer for health, reauth, and event automations. Realtime keeps connection
// state live without a manual refresh.
"use client";
import React from "react";
import {
CableIcon,
CalendarCheckIcon,
CheckIcon,
PlusIcon,
RefreshCwIcon,
XIcon,
} from "lucide-react";
import { CalendarCheckIcon, ExternalLinkIcon, RefreshCwIcon, SettingsIcon } from "lucide-react";
import toast from "react-hot-toast";
import {
@@ -32,63 +21,73 @@ import {
Stat,
StatStrip,
} from "@/components/layout/Page";
import { SearchInput } from "@/components/ui/field";
import useIntegrationCatalog from "@/lib/api/hooks/app/integrations/useIntegrationCatalog";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
import useMeetingBookings from "@/lib/api/hooks/app/integrations/useMeetingBookings";
import type {
IntegrationCatalogEntry,
IntegrationCategory,
IntegrationConnection,
IntegrationProvider,
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
type IntegrationCatalogEntry,
type IntegrationCategory,
type IntegrationConnection,
type IntegrationProvider,
} from "@/lib/api/models/app/integrations/Integration";
import { cn } from "@/lib/utils";
import ConnectDrawer from "./_components/ConnectDrawer";
import ConnectionDetail from "./_components/ConnectionDetail";
import InboundUrlDialog from "./_components/InboundUrlDialog";
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
crm: "CRM",
automation: "Automation",
notifications: "Notifications",
meetings: "Meetings",
data: "Data",
};
const CATEGORY_ORDER: IntegrationCategory[] = ["crm", "automation", "notifications", "meetings", "data"];
import ProviderGlyph from "./_components/ProviderGlyph";
import StatusPill from "./_components/StatusPill";
export default function IntegrationsPage() {
const catalogQuery = useIntegrationCatalog();
const connectionsQuery = useIntegrationConnections();
const bookingsQuery = useMeetingBookings();
const disconnect = useDisconnectIntegration();
const [connectTarget, setConnectTarget] = React.useState<IntegrationCatalogEntry | null>(null);
const [manageTarget, setManageTarget] = React.useState<IntegrationConnection | null>(null);
const [inboundUrl, setInboundUrl] = React.useState<{ provider: IntegrationProvider; url: string } | null>(null);
const [query, setQuery] = React.useState("");
const catalog = catalogQuery.data?.catalog ?? [];
const connections = connectionsQuery.data?.connections ?? [];
const bookings = bookingsQuery.data?.bookings ?? [];
const byProvider = React.useMemo(() => {
const m: Record<string, IntegrationConnection[]> = {};
for (const c of connections) {
(m[c.provider] ??= []).push(c);
}
const entryByProvider = React.useMemo(() => {
const m: Record<string, IntegrationCatalogEntry> = {};
for (const e of catalog) m[e.provider] = e;
return m;
}, [catalog]);
const firstConnByProvider = React.useMemo(() => {
const m: Record<string, IntegrationConnection> = {};
for (const c of connections) if (!m[c.provider]) m[c.provider] = c;
return m;
}, [connections]);
const q = query.trim().toLowerCase();
const filtered = React.useMemo(() => {
if (!q) return catalog;
return catalog.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
e.tagline.toLowerCase().includes(q) ||
e.category.toLowerCase().includes(q),
);
}, [catalog, q]);
const grouped = React.useMemo(() => {
const map: Partial<Record<IntegrationCategory, IntegrationCatalogEntry[]>> = {};
for (const entry of catalog) {
(map[entry.category] ??= []).push(entry);
}
for (const entry of filtered) (map[entry.category] ??= []).push(entry);
return map;
}, [catalog]);
}, [filtered]);
const connectedCount = connections.filter((c) => c.status === "connected").length;
const degradedCount = connections.filter((c) => c.status === "degraded").length;
const attentionCount = connections.filter(
(c) => c.status === "degraded" || c.status === "reauth_required",
).length;
function refreshAll() {
catalogQuery.refetch();
@@ -96,54 +95,56 @@ export default function IntegrationsPage() {
bookingsQuery.refetch();
}
async function handleDisconnect(connection: IntegrationConnection) {
try {
await disconnect.mutateAsync(connection.id);
toast.success("Disconnected");
} catch {
toast.error("Disconnect failed");
}
function onCardClick(entry: IntegrationCatalogEntry) {
const existing = firstConnByProvider[entry.provider];
if (existing) setManageTarget(existing);
else setConnectTarget(entry);
}
return (
<Page>
<PageTopbar eyebrow="Integrations" subtitle="CRMs, automation, notifications, meetings, and data">
<button
type="button"
onClick={refreshAll}
aria-label="Refresh"
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
>
<RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} />
</button>
<PageTopbar eyebrow="Integrations" subtitle="Connect your stack — CRMs, alerts, automation, meetings, and data">
<div className="flex items-center gap-2">
<div className="w-48 hidden sm:block">
<SearchInput value={query} onChange={setQuery} placeholder="Search integrations" />
</div>
<button
type="button"
onClick={refreshAll}
aria-label="Refresh"
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
>
<RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} />
</button>
</div>
</PageTopbar>
<StatStrip cols={4}>
<Stat
label="Catalog"
value={catalog.length}
sub="available providers"
/>
<Stat
label="Connected"
value={connectedCount}
sub={`${connections.length} total`}
accent={connectedCount > 0}
/>
<Stat
label="Degraded"
value={degradedCount}
sub={degradedCount > 0 ? "needs attention" : "all healthy"}
/>
<Stat
label="Meetings"
value={bookings.length}
sub="from Calendly + Cal.com"
last
/>
<Stat label="Available" value={catalog.length} sub="providers" />
<Stat label="Connected" value={connectedCount} sub={`${connections.length} total`} accent={connectedCount > 0} />
<Stat label="Needs attention" value={attentionCount} sub={attentionCount > 0 ? "reconnect / errors" : "all healthy"} />
<Stat label="Meetings" value={bookings.length} sub="booked via integrations" last />
</StatStrip>
<PageBody>
{/* Connected rail */}
{connections.length > 0 && (
<section>
<SectionBar label="Your connections" count={connections.length} />
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{connections.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
entry={entryByProvider[c.provider]}
onManage={() => setManageTarget(c)}
/>
))}
</div>
</section>
)}
{/* Catalog by category */}
{CATEGORY_ORDER.map((category) => {
const entries = grouped[category] ?? [];
if (entries.length === 0) return null;
@@ -155,10 +156,8 @@ export default function IntegrationsPage() {
<CatalogCard
key={entry.provider}
entry={entry}
connections={byProvider[entry.provider] ?? []}
onConnect={() => setConnectTarget(entry)}
onDisconnect={handleDisconnect}
onShowInbound={(url) => setInboundUrl({ provider: entry.provider, url })}
connection={firstConnByProvider[entry.provider]}
onClick={() => onCardClick(entry)}
/>
))}
</div>
@@ -166,19 +165,29 @@ export default function IntegrationsPage() {
);
})}
{q && filtered.length === 0 && (
<EmptyBlock title="No matches" body={`Nothing in the catalog matches “${query}”.`} />
)}
{/* Meetings */}
<SectionBar label="Meeting bookings" count={bookings.length}>
<CalendarCheckIcon className="w-3 h-3 text-slate-400" />
</SectionBar>
{bookings.length === 0 ? (
<EmptyBlock
title="No meetings booked yet"
body="Connect Calendly or Cal.com to track which campaigns lead to meetings."
body="Connect Calendly or Cal.com to credit booked meetings to the campaign that surfaced the lead."
/>
) : (
<div className="divide-y divide-slate-200/60 border-b border-slate-200/60">
{bookings.slice(0, 10).map((b) => (
{bookings.slice(0, 12).map((b) => (
<div key={b.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]">
<SourceDot source={b.source} />
<span
className={cn(
"size-1.5 rounded-full shrink-0",
b.source === "calendly" ? "bg-rose-400" : "bg-indigo-400",
)}
/>
<span className="font-medium text-slate-900 truncate w-32 sm:w-60">{b.invitee_email}</span>
<span className="text-slate-500 truncate flex-1">{b.event_name}</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
@@ -195,12 +204,26 @@ export default function IntegrationsPage() {
entry={connectTarget}
onClose={() => setConnectTarget(null)}
onConnected={(conn) => {
connectionsQuery.refetch();
if (conn.inbound_webhook_url) {
setInboundUrl({ provider: conn.provider, url: conn.inbound_webhook_url });
} else {
// Drop straight into management so the user can wire automations.
setManageTarget(conn);
}
}}
/>
)}
{manageTarget && (
<ConnectionDetail
connection={manageTarget}
entry={entryByProvider[manageTarget.provider]}
onClose={() => {
setManageTarget(null);
connectionsQuery.refetch();
}}
/>
)}
{inboundUrl && (
<InboundUrlDialog
provider={inboundUrl.provider}
@@ -214,42 +237,36 @@ export default function IntegrationsPage() {
function CatalogCard({
entry,
connections,
onConnect,
onDisconnect,
onShowInbound,
connection,
onClick,
}: {
entry: IntegrationCatalogEntry;
connections: IntegrationConnection[];
onConnect: () => void;
onDisconnect: (c: IntegrationConnection) => void;
onShowInbound: (url: string) => void;
connection?: IntegrationConnection;
onClick: () => void;
}) {
const connected = connections.length > 0;
const status = connected ? connections[0].status : "disconnected";
const connected = !!connection;
const comingSoon = entry.auth_method === "oauth" && !entry.configured && !connected;
return (
<div className="bg-white p-5 flex flex-col min-h-[140px]">
<button
type="button"
onClick={onClick}
className="text-left bg-white p-5 flex flex-col min-h-[150px] hover:bg-slate-50/60 transition-colors group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-9 h-9 rounded-md bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[13px] font-semibold uppercase">
{entry.name.charAt(0)}
</div>
<ProviderGlyph provider={entry.provider} name={entry.name} />
<div className="min-w-0">
<div className="text-[13px] font-semibold text-slate-900 truncate">{entry.name}</div>
<div className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-mono">
{entry.auth_method}
{entry.beta && (
<span className="ml-1.5 text-amber-600">· beta</span>
)}
<div className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">
{entry.auth_method === "oauth" ? "one-click" : entry.auth_method}
{entry.beta && <span className="ml-1.5 text-amber-600">· beta</span>}
</div>
</div>
</div>
<StatusPill status={status} />
{connected ? <StatusPill status={connection.status} /> : comingSoon ? <ComingSoon /> : null}
</div>
<p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-3">
{entry.tagline}
</p>
<p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-2">{entry.tagline}</p>
<div className="mt-auto pt-3 flex items-center justify-between gap-2">
{entry.docs_url ? (
@@ -257,95 +274,76 @@ function CatalogCard({
href={entry.docs_url}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-slate-500 hover:text-sky-700 underline decoration-dotted underline-offset-2"
onClick={(e) => e.stopPropagation()}
className="text-[11px] text-slate-400 hover:text-sky-700 inline-flex items-center gap-1 underline decoration-dotted underline-offset-2"
>
<ExternalLinkIcon className="w-3 h-3" />
Docs
</a>
) : <span />}
<div className="flex items-center gap-1.5">
{connected && connections[0].id ? (
<>
<button
type="button"
onClick={() => onDisconnect(connections[0])}
className="h-6 px-2 rounded text-[11px] text-slate-500 hover:text-rose-700 hover:bg-rose-50 transition-colors"
>
Disconnect
</button>
{connections[0].display_fields && Object.keys(connections[0].display_fields).length > 0 && (
<span className="font-mono text-[10px] text-slate-400 truncate max-w-[120px]">
{(connections[0].display_fields as Record<string, string>)["workspace"] ??
(connections[0].display_fields as Record<string, string>)["sheet_title"] ??
(connections[0].display_fields as Record<string, string>)["account_email"] ??
(connections[0].display_fields as Record<string, string>)["channel"] ??
""}
</span>
)}
</>
) : (
<button
type="button"
onClick={onConnect}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Connect
</button>
) : (
<span />
)}
<span
className={cn(
"h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors",
connected
? "text-slate-600 group-hover:text-slate-900 group-hover:bg-slate-100"
: comingSoon
? "text-slate-300"
: "bg-sky-600 text-white group-hover:bg-sky-700",
)}
</div>
</div>
{connected && entry.webhook_hint && (
<button
type="button"
onClick={() => {
onShowInbound("/api/v1/integrations/inbound/" + entry.provider.replace("_", "-") + "/<your-secret>");
}}
className="mt-2 text-[10.5px] text-sky-700 hover:underline self-start inline-flex items-center gap-1"
>
<CableIcon className="w-3 h-3" />
Webhook URL
</button>
)}
</div>
{connected ? (
<>
<SettingsIcon className="w-3 h-3" />
Manage
</>
) : comingSoon ? (
"Coming soon"
) : (
"Connect"
)}
</span>
</div>
</button>
);
}
function StatusPill({ status }: { status: string }) {
const tone =
status === "connected"
? { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" }
: status === "degraded"
? { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" }
: status === "pending"
? { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500" }
: { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400" };
const label = status === "disconnected" ? "not connected" : status;
function ConnectionCard({
connection,
entry,
onManage,
}: {
connection: IntegrationConnection;
entry?: IntegrationCatalogEntry;
onManage: () => void;
}) {
const account =
connection.external_account_name ||
(connection.display_fields as Record<string, string>)?.account ||
(connection.display_fields as Record<string, string>)?.workspace ||
(connection.display_fields as Record<string, string>)?.channel ||
"";
return (
<span
className={cn(
"inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium",
tone.bg,
tone.text,
)}
<button
type="button"
onClick={onManage}
className="text-left bg-white p-4 flex items-center gap-3 hover:bg-slate-50/60 transition-colors"
>
<span className={cn("size-1.5 rounded-full", tone.dot)} />
{label}
</span>
<ProviderGlyph provider={connection.provider} name={entry?.name ?? connection.label} />
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-semibold text-slate-900 truncate">{connection.label}</div>
<div className="text-[11px] text-slate-400 truncate">{account || (entry?.name ?? connection.provider)}</div>
</div>
<StatusPill status={connection.status} />
</button>
);
}
function SourceDot({ source }: { source: string }) {
const colour = source === "calendly" ? "bg-rose-400" : "bg-indigo-400";
function ComingSoon() {
return (
<span className="inline-flex items-center gap-1">
<span className={cn("size-1.5 rounded-full", colour)} />
<span className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">
{source === "calendly" ? "calendly" : "cal.com"}
</span>
<span className="inline-flex items-center h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-slate-100 text-slate-400">
soon
</span>
);
}
void CheckIcon;
void XIcon;
+1 -1
View File
@@ -101,7 +101,7 @@ export default function MembersSettingsPage() {
try {
await toast.promise(
updateRole.mutateAsync({
memberId,
id: memberId,
data: { role: nextRole },
}),
{
+3 -3
View File
@@ -1,7 +1,7 @@
import { Loading } from "@/components/loader";
import { useError } from "@/hooks/ErrorProvider";
import type { Tag} from "@/hooks/UserProvider";
import { User, useUser } from "@/hooks/UserProvider";
import type { Tag } from "@/hooks/UserProvider";
import { useUser } from "@/hooks/UserProvider";
import { APIError, Call } from "@/lib/api";
import {
DndContext,
@@ -368,4 +368,4 @@ const ColorInput = ({color, setColor}:{color: string, setColor: React.Dispatch<R
</ColorBox>
</div>
)
}
}
+19 -2
View File
@@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { UserContext } from './context/user';
import useUser from '@/lib/api/hooks/auth/useUser';
import { useUserProfile } from './context/user';
import useAuthUser from '@/lib/api/hooks/auth/useUser';
import { AnimatePresence } from 'framer-motion';
import LoadingScreen from '@/components/LoadingScreen';
import useRoles from '@/lib/api/hooks/app/admin/roles/useRoles';
@@ -17,7 +19,8 @@ const EMPTY_ACCESS: Access = { roles: [], permissions: [] };
const EMPTY_TIMEZONES: Timezone[] = [];
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
const user = useUser();
const queryClient = useQueryClient();
const user = useAuthUser();
const access = useRoles();
const timezones = useTimezones();
@@ -56,6 +59,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
}
}, [user.error, access.error, timezones.error]);
const setUser = React.useCallback<React.Dispatch<React.SetStateAction<User | null>>>((value) => {
queryClient.setQueryData<User | null>(["auth", "me"], (old) => (
typeof value === "function"
? (value as (prev: User | null) => User | null)(old ?? null)
: value
));
}, [queryClient]);
if (error?.redirect) {
clearTokens();
return <Navigate to="/auth/login" replace />;
@@ -87,6 +98,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
user: safeUser,
access: access.data ?? EMPTY_ACCESS,
timezones: timezones.data ?? EMPTY_TIMEZONES,
setUser,
tagsEdit,
setTagsEdit,
foldersEdit,
@@ -98,3 +110,8 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
</UserContext.Provider>
);
};
export { useUserProfile as useUser };
export type { default as Folder } from "@/lib/api/models/app/Folder";
export type { default as Tag } from "@/lib/api/models/app/Tag";
export type { default as User } from "@/lib/api/models/auth/User";
+2
View File
@@ -26,6 +26,8 @@ interface SocketContextValue {
// Connection state
isConnected: boolean;
reconnectAttempt: number;
error?: boolean;
message?: string;
// Channel management
joinChannel: (topic: string, params?: Record<string, unknown>) => void;
+1
View File
@@ -7,6 +7,7 @@ interface UserC {
user: User;
access: Access;
timezones: Timezone[];
setUser: React.Dispatch<React.SetStateAction<User | null>>;
tagsEdit: boolean;
setTagsEdit: React.Dispatch<React.SetStateAction<boolean>>;
foldersEdit: boolean;
+11
View File
@@ -162,6 +162,17 @@ export function useRealtimeEvents() {
return
}
if (includes('INTEGRATION', 'CONNECTION', 'BOOKING', 'MEETING')) {
invalidate([
['integrations', 'connections'],
['integrations', 'catalog'],
['integrations', 'bookings'],
])
const connectionId = getString('connection_id')
if (connectionId) invalidate([['integrations', 'connection', connectionId]])
return
}
if (includes('TEMPLATE')) {
invalidate([['templates']])
return
@@ -0,0 +1,24 @@
import type { IntegrationEventSubscription } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
export interface CreateConnectionEventInput {
connectionId: string;
event_type: string;
action: string;
config?: Record<string, unknown>;
enabled?: boolean;
}
// Wires a Warmbly event (e.g. campaign.reply_received) to a provider action
// (e.g. slack.notify) on a connection.
export default async function createConnectionEvent(
input: CreateConnectionEventInput,
): Promise<IntegrationEventSubscription> {
const { connectionId, ...body } = input;
return await Request<IntegrationEventSubscription>({
method: "POST",
url: `/integrations/connections/${connectionId}/events`,
data: body,
authorization: true,
});
}
@@ -0,0 +1,13 @@
import Request from "../../Request";
// Removes an event→action subscription from a connection.
export default async function deleteConnectionEvent(input: {
connectionId: string;
eventId: string;
}): Promise<void> {
await Request<void>({
method: "DELETE",
url: `/integrations/connections/${input.connectionId}/events/${input.eventId}`,
authorization: true,
});
}
@@ -0,0 +1,16 @@
import type { IntegrationConnection } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
// Completes the OAuth handshake: the backend validates state, exchanges the
// code, resolves the connected account, and stores encrypted tokens.
export default async function finishIntegrationOAuth(input: {
code: string;
state: string;
}): Promise<IntegrationConnection> {
return await Request<IntegrationConnection>({
method: "POST",
url: "/integrations/oauth/finish",
data: input,
authorization: true,
});
}
@@ -0,0 +1,12 @@
import type { IntegrationConnectionDetail } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
// Returns a single connection plus its event subscriptions and recent sync
// runs — the connection-management drawer payload.
export default async function getConnection(id: string): Promise<IntegrationConnectionDetail> {
return await Request<IntegrationConnectionDetail>({
method: "GET",
url: `/integrations/connections/${id}`,
authorization: true,
});
}
@@ -0,0 +1,13 @@
import type { IntegrationOAuthStartResponse } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
// Starts a fresh OAuth handshake for an existing connection whose token
// expired or was revoked. Returns a new authorization URL.
export default async function reauthIntegration(id: string): Promise<IntegrationOAuthStartResponse> {
return await Request<IntegrationOAuthStartResponse>({
method: "POST",
url: `/integrations/oauth/reauth/${id}`,
data: {},
authorization: true,
});
}
@@ -0,0 +1,16 @@
import type { IntegrationOAuthStartResponse } from "@/lib/api/models/app/integrations/Integration";
import Request from "../../Request";
// Starts an OAuth handshake. Returns the provider authorization URL the SPA
// opens in a popup; the backend mints + stores the CSRF state / PKCE verifier.
export default async function startIntegrationOAuth(input: {
provider: string;
label?: string;
}): Promise<IntegrationOAuthStartResponse> {
return await Request<IntegrationOAuthStartResponse>({
method: "POST",
url: "/integrations/oauth/start",
data: input,
authorization: true,
});
}
@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import getConnection from "@/lib/api/client/app/integrations/getConnection";
export default function useConnectionDetail(id: string | null) {
return useQuery({
queryKey: ["integrations", "connection", id],
queryFn: () => getConnection(id as string),
enabled: !!id,
});
}
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import createConnectionEvent from "@/lib/api/client/app/integrations/createConnectionEvent";
import deleteConnectionEvent from "@/lib/api/client/app/integrations/deleteConnectionEvent";
export function useCreateConnectionEvent() {
const qc = useQueryClient();
return useMutation({
mutationFn: createConnectionEvent,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
},
});
}
export function useDeleteConnectionEvent() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteConnectionEvent,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
},
});
}
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import startIntegrationOAuth from "@/lib/api/client/app/integrations/startIntegrationOAuth";
import finishIntegrationOAuth from "@/lib/api/client/app/integrations/finishIntegrationOAuth";
import reauthIntegration from "@/lib/api/client/app/integrations/reauthIntegration";
// useStartIntegrationOAuth returns the provider authorization URL + state.
export function useStartIntegrationOAuth() {
return useMutation({ mutationFn: startIntegrationOAuth });
}
// useFinishIntegrationOAuth completes the handshake and refreshes connections.
export function useFinishIntegrationOAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: finishIntegrationOAuth,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
qc.invalidateQueries({ queryKey: ["integrations", "catalog"] });
},
});
}
// useReauthIntegration starts a fresh handshake for an existing connection.
export function useReauthIntegration() {
return useMutation({ mutationFn: reauthIntegration });
}
@@ -1,5 +1,5 @@
// Mirror of the backend's models/integration.go shapes. Only the fields
// the dashboard renders are typed; opaque blobs like display_fields stay
// Mirror of the backend's models/integration.go shapes. Only the fields the
// dashboard renders are typed; opaque blobs like display_fields / config stay
// generic so the UI can dig in without round-trips to the type system.
export type IntegrationProvider =
@@ -16,7 +16,17 @@ export type IntegrationProvider =
| "cal_com"
| "google_sheets";
export type IntegrationStatus = "pending" | "connected" | "degraded" | "disconnected";
export type IntegrationAuthMethod = "oauth" | "api_key" | "webhook";
export type IntegrationStatus =
| "pending"
| "authorizing"
| "connected"
| "degraded"
| "reauth_required"
| "disconnected";
export type IntegrationHealth = "unknown" | "healthy" | "degraded" | "down";
export type IntegrationCategory =
| "crm"
@@ -31,10 +41,15 @@ export interface IntegrationCatalogEntry {
tagline: string;
category: IntegrationCategory;
docs_url?: string;
auth_method: "oauth" | "api_key" | "webhook";
auth_method: IntegrationAuthMethod;
badge_color?: string;
beta: boolean;
webhook_hint?: string;
highlights?: string[];
scopes?: string[];
events?: string[];
/** Whether the server has OAuth client credentials wired for this provider. */
configured: boolean;
}
export interface IntegrationConnection {
@@ -43,7 +58,16 @@ export interface IntegrationConnection {
provider: IntegrationProvider;
label: string;
status: IntegrationStatus;
auth_method: IntegrationAuthMethod;
display_fields: Record<string, unknown>;
connected_by_user_id?: string | null;
external_account_id?: string;
external_account_name?: string;
granted_scopes?: string[];
token_expires_at?: string | null;
health: IntegrationHealth;
health_detail?: string | null;
health_checked_at?: string | null;
last_synced_at?: string | null;
last_error?: string | null;
last_error_at?: string | null;
@@ -54,6 +78,49 @@ export interface IntegrationConnection {
inbound_webhook_url?: string;
}
export type IntegrationAction =
| "slack.notify"
| "discord.notify"
| "hubspot.upsert_contact"
| "pipedrive.upsert_person"
| "google_sheets.append_row"
| "webhook.ping";
export interface IntegrationEventSubscription {
id: string;
connection_id: string;
organization_id: string;
event_type: string;
action: IntegrationAction;
config: Record<string, unknown>;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface IntegrationSyncRun {
id: string;
connection_id: string;
organization_id: string;
kind: string;
status: "running" | "success" | "error";
detail: string;
records_processed: number;
started_at: string;
finished_at?: string | null;
}
export interface IntegrationOAuthStartResponse {
url: string;
state: string;
}
export interface IntegrationConnectionDetail {
connection: IntegrationConnection;
events: IntegrationEventSubscription[];
runs: IntegrationSyncRun[];
}
export interface MeetingBooking {
id: string;
organization_id: string;
@@ -67,3 +134,58 @@ export interface MeetingBooking {
campaign_id?: string;
created_at: string;
}
// --- presentation helpers (shared by cards + drawers) ----------------------
export const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
crm: "CRM",
automation: "Automation",
notifications: "Notifications",
meetings: "Meetings",
data: "Data",
};
export const CATEGORY_ORDER: IntegrationCategory[] = [
"crm",
"notifications",
"automation",
"meetings",
"data",
];
// Reply-intent classifier buckets, used to filter reply automations
// ("only notify me on positive replies"). Mirrors models.ReplyIntentType.
export const REPLY_INTENT_OPTIONS: { value: string; label: string }[] = [
{ value: "positive", label: "Positive" },
{ value: "question", label: "Question" },
{ value: "neutral", label: "Neutral" },
{ value: "negative", label: "Negative" },
{ value: "out_of_office", label: "Out of office" },
];
// Human labels for the Warmbly event vocabulary (subset surfaced as triggers).
export const EVENT_LABELS: Record<string, string> = {
"campaign.reply_received": "Prospect replies",
"campaign.email_bounced": "Email bounces",
"campaign.unsubscribed": "Contact unsubscribes",
"warmup.health_changed": "Warmup health changes",
"deliverability.complaint": "Spam complaint",
};
// Which action a provider performs for an event subscription.
export function defaultActionForProvider(provider: IntegrationProvider): IntegrationAction {
switch (provider) {
case "slack":
return "slack.notify";
case "discord":
return "discord.notify";
case "hubspot":
return "hubspot.upsert_contact";
case "pipedrive":
return "pipedrive.upsert_person";
case "google_sheets":
return "google_sheets.append_row";
default:
return "webhook.ping";
}
}
+70
View File
@@ -0,0 +1,70 @@
// Drives the OAuth connect popup for third-party integrations. Mirrors the
// mailbox-onboarding flow: we open the provider authorization URL in a centered
// popup; the backend's /integrations/oauth/callback page postMessages the
// {code, state} back to this opener; we resolve with them so the caller can
// finish the handshake. The window-name carries no secret — the CSRF/PKCE
// state lives server-side, keyed by the `state` nonce.
export interface OAuthPopupResult {
code: string;
state: string;
}
const POPUP_MESSAGE_SOURCE = "warmbly-integration-oauth";
export function openOAuthPopup(authUrl: string): Promise<OAuthPopupResult> {
return new Promise((resolve, reject) => {
const width = 600;
const height = 720;
const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2);
const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2);
const popup = window.open(
authUrl,
"warmbly_oauth",
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
);
if (!popup) {
reject(new Error("Popup blocked. Allow popups for this site and try again."));
return;
}
let settled = false;
const cleanup = () => {
window.removeEventListener("message", onMessage);
window.clearInterval(closedTimer);
};
const onMessage = (event: MessageEvent) => {
const data = event.data as
| { source?: string; code?: string; state?: string; error?: string }
| undefined;
if (!data || data.source !== POPUP_MESSAGE_SOURCE) return;
settled = true;
cleanup();
try {
popup.close();
} catch {
/* ignore */
}
if (data.error) {
reject(new Error(data.error));
return;
}
if (data.code && data.state) {
resolve({ code: data.code, state: data.state });
return;
}
reject(new Error("Authorization was cancelled."));
};
window.addEventListener("message", onMessage);
// Detect a manually-closed popup so the caller's promise doesn't hang.
const closedTimer = window.setInterval(() => {
if (popup.closed && !settled) {
cleanup();
reject(new Error("Authorization window was closed before finishing."));
}
}, 600);
});
}