Files
warmbly/internal/api/handler/integration.go
T
2026-06-01 04:49:50 +02:00

466 lines
15 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"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"
)
// 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
}
// 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 {
return
}
conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID)
if err != nil {
errx.JSON(c, errx.New(errx.Internal, "failed to list connections"))
return
}
c.JSON(http.StatusOK, gin.H{"connections": conns})
}
// 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 {
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 {
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
}
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityIntegration, &subID, nil, map[string]string{"detail": "event_subscription"})
c.Status(http.StatusNoContent)
}
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) ----------------------------------
func (h *Handler) InboundCalendly(c *gin.Context) {
h.handleInboundBooking(c, models.IntegrationCalendly)
}
func (h *Handler) InboundCalCom(c *gin.Context) {
h.handleInboundBooking(c, models.IntegrationCalCom)
}
func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) {
secret := strings.TrimSpace(c.Param("secret"))
if secret == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"})
return
}
conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), provider, secret)
if err != nil || conn == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"})
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
return
}
matcher := integration.NewBookingMatcher(func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
if h.ContactRepo == nil {
return nil, nil
}
contact, xerr := h.ContactRepo.GetByEmailAndOrganization(ctx, orgID, email)
if xerr != nil {
return nil, xerr
}
if contact == nil {
return nil, nil
}
return &contact.ID, nil
})
var booking *models.MeetingBooking
switch provider {
case models.IntegrationCalendly:
booking, err = integration.HandleCalendlyEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
case models.IntegrationCalCom:
booking, err = integration.HandleCalComEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if booking != nil {
data := map[string]any{
"source": booking.Source,
"invitee_email": booking.InviteeEmail,
"event_name": booking.EventName,
"scheduled_for": booking.ScheduledFor,
"contact_id": booking.ContactID,
"booking_id": booking.ID,
"trigger": "meeting_booked",
}
// 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})
}
// ListMeetingBookings surfaces booked meetings for the integrations page.
func (h *Handler) ListMeetingBookings(c *gin.Context) {
orgID, ok := requireOrgID(c)
if !ok {
return
}
rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50)
if err != nil {
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
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
return
}
h.AuditService.LogAction(c.Request.Context(), *orgID, userID, action, models.AuditEntityIntegration, &id, c.ClientIP(), c.Request.UserAgent(), nil, meta)
}