mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
8d3b6b5d05
The deliverability-data and DNS-write integrations were over-engineered for the cold-email segment. Postmaster and SNDS require sending volume our base typically does not hit, and no comparable cold-email tool exposes DMARC ingestion or native DNS writes. Replaces the catalog with the standard set: HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack, Discord, Calendly, Cal.com, Google Sheets. Removes dmarc_reports, dmarc_record_rows, postmaster_snapshots, and dns_verifications tables from the migration. Deletes dmarc.go, dns.go, cloudflare.go, postmaster.go from the integration package. Prunes the matching repository methods and HTTP handlers.
186 lines
5.9 KiB
Go
186 lines
5.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/integration"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// ListIntegrationCatalog returns the static metadata for every integration
|
|
// Warmbly supports. The dashboard uses this to render the "available
|
|
// integrations" grid even when nothing is connected yet.
|
|
func (h *Handler) ListIntegrationCatalog(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"catalog": h.IntegrationService.Catalog(),
|
|
})
|
|
}
|
|
|
|
// ListIntegrationConnections returns this org's connection rows.
|
|
// No secrets, no encrypted config.
|
|
func (h *Handler) ListIntegrationConnections(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"connections": conns})
|
|
}
|
|
|
|
// integrationConnectPayload is the create-connection request body. The
|
|
// `config` map is per-provider; see integration.buildDisplayFields for
|
|
// which keys are recognized.
|
|
type integrationConnectPayload struct {
|
|
Provider string `json:"provider"`
|
|
Label string `json:"label"`
|
|
Config map[string]any `json:"config"`
|
|
}
|
|
|
|
// ConnectIntegration creates or updates a connection. For inbound-webhook
|
|
// providers (Calendly, Cal.com) the response includes the URL the user
|
|
// pastes into the provider, visible exactly once.
|
|
func (h *Handler) ConnectIntegration(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var p integrationConnectPayload
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
provider := models.IntegrationProvider(strings.TrimSpace(p.Provider))
|
|
if !models.IsValidIntegrationProvider(string(provider)) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown provider"})
|
|
return
|
|
}
|
|
|
|
conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, provider, p.Label, p.Config)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, conn)
|
|
}
|
|
|
|
// DisconnectIntegration removes a connection row. Cascading FKs handle
|
|
// dependent data (bookings) per the migration.
|
|
func (h *Handler) DisconnectIntegration(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := h.IntegrationService.Disconnect(c.Request.Context(), orgID, id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// Inbound webhooks
|
|
//
|
|
// Per-provider inbound endpoints. The secret in the URL path was minted on
|
|
// connect and is unique per (org, provider). No org context comes from
|
|
// the auth middleware here because providers POST from their own
|
|
// infrastructure with no Warmbly bearer.
|
|
|
|
// InboundCalendly handles invitee.created webhooks. The secret in the URL
|
|
// routes the event to the right org.
|
|
func (h *Handler) InboundCalendly(c *gin.Context) {
|
|
h.handleInboundBooking(c, models.IntegrationCalendly)
|
|
}
|
|
|
|
func (h *Handler) InboundCalCom(c *gin.Context) {
|
|
h.handleInboundBooking(c, models.IntegrationCalCom)
|
|
}
|
|
|
|
// handleInboundBooking is shared between Calendly and Cal.com. The
|
|
// per-provider parsing logic differs but the routing (secret to org, save
|
|
// booking, fire webhook) is identical.
|
|
func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) {
|
|
secret := strings.TrimSpace(c.Param("secret"))
|
|
if secret == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"})
|
|
return
|
|
}
|
|
conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), provider, secret)
|
|
if err != nil || conn == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"})
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
|
|
return
|
|
}
|
|
|
|
matcher := integration.NewBookingMatcher(func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
|
|
if h.ContactRepo == nil {
|
|
return nil, nil
|
|
}
|
|
contact, xerr := h.ContactRepo.GetByEmailAndOrganization(ctx, orgID, email)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if contact == nil {
|
|
return nil, nil
|
|
}
|
|
return &contact.ID, nil
|
|
})
|
|
|
|
var booking *models.MeetingBooking
|
|
switch provider {
|
|
case models.IntegrationCalendly:
|
|
booking, err = integration.HandleCalendlyEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
|
|
case models.IntegrationCalCom:
|
|
booking, err = integration.HandleCalComEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
|
|
}
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if booking != nil && h.WebhookService != nil {
|
|
_, _ = h.WebhookService.Dispatch(c.Request.Context(), conn.OrganizationID, models.WebhookEventCampaignReplyReceived, map[string]any{
|
|
"source": booking.Source,
|
|
"invitee_email": booking.InviteeEmail,
|
|
"event_name": booking.EventName,
|
|
"scheduled_for": booking.ScheduledFor,
|
|
"contact_id": booking.ContactID,
|
|
"booking_id": booking.ID,
|
|
"trigger": "meeting_booked",
|
|
})
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"received": true})
|
|
}
|
|
|
|
// ListMeetingBookings surfaces booked meetings for the integrations page.
|
|
func (h *Handler) ListMeetingBookings(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"bookings": rows})
|
|
}
|