package handler import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) // requireLeadSyncActor resolves the org + user for a lead-sync request the same // way the contact and integration handlers do. func (h *Handler) requireLeadSyncActor(c *gin.Context) (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 } return orgID, uid, true } // leadSyncConnectionResponse is the minimal connection shape the lead-sync UI // needs to decide whether to show "Connect Google" or the sheet picker. type leadSyncConnectionResponse struct { Connected bool `json:"connected"` Connection *leadSyncConnectionSummary `json:"connection"` } type leadSyncConnectionSummary struct { ID uuid.UUID `json:"id"` ExternalAccountName string `json:"external_account_name"` Status string `json:"status"` } // GetLeadSyncGoogleConnection reports whether the org has a connected Google // account usable for lead-sync (the hidden google_sheets OAuth connection). func (h *Handler) GetLeadSyncGoogleConnection(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { return } conn, err := h.LeadSyncService.Connection(c.Request.Context(), orgID) if err != nil { errx.JSON(c, errx.New(errx.Internal, "failed to resolve google connection")) return } if conn == nil { c.JSON(http.StatusOK, leadSyncConnectionResponse{Connected: false, Connection: nil}) return } c.JSON(http.StatusOK, leadSyncConnectionResponse{ Connected: true, Connection: &leadSyncConnectionSummary{ ID: conn.ID, ExternalAccountName: conn.ExternalAccountName, Status: string(conn.Status), }, }) } type leadSyncSpreadsheetPayload struct { ConnectionID string `json:"connection_id"` SheetID string `json:"sheet_id"` } // GetLeadSyncSpreadsheet returns a sheet's title + tabs so the UI can render a // tab picker before mapping columns. func (h *Handler) GetLeadSyncSpreadsheet(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { return } var p leadSyncSpreadsheetPayload if err := c.ShouldBindJSON(&p); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) return } connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) return } if strings.TrimSpace(p.SheetID) == "" { errx.JSON(c, errx.New(errx.BadRequest, "sheet_id is required")) return } meta, merr := h.LeadSyncService.SpreadsheetMeta(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID)) if merr != nil { errx.JSON(c, errx.New(errx.BadRequest, "failed to read spreadsheet: "+merr.Error())) return } c.JSON(http.StatusOK, meta) } type leadSyncPreviewPayload struct { ConnectionID string `json:"connection_id"` SheetID string `json:"sheet_id"` TabTitle string `json:"tab_title"` } // PreviewLeadSync returns an ImportPreview-shaped payload (columns, sample_rows, // total_rows, has_header, suggested_mapping) so the frontend reuses its // contact-import column mapper verbatim. func (h *Handler) PreviewLeadSync(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { return } var p leadSyncPreviewPayload if err := c.ShouldBindJSON(&p); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) return } connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) return } preview, xerr := h.LeadSyncService.Preview(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID), strings.TrimSpace(p.TabTitle)) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, preview) } // ListLeadSyncSources lists this org's saved sources, optionally filtered to a // campaign via ?campaign_id=. func (h *Handler) ListLeadSyncSources(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { return } var campaignID *uuid.UUID if raw := strings.TrimSpace(c.Query("campaign_id")); raw != "" { id, err := uuid.Parse(raw) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid campaign_id")) return } campaignID = &id } sources, err := h.LeadSyncService.List(c.Request.Context(), orgID, campaignID) if err != nil { errx.JSON(c, errx.New(errx.Internal, "failed to list sync sources")) return } if sources == nil { sources = []models.LeadSyncSource{} } c.JSON(http.StatusOK, gin.H{"data": sources}) } // CreateLeadSyncSource saves a new on-demand sync source. func (h *Handler) CreateLeadSyncSource(c *gin.Context) { orgID, userID, ok := h.requireLeadSyncActor(c) if !ok { return } var in models.CreateLeadSyncSource if err := c.ShouldBindJSON(&in); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) return } src, xerr := h.LeadSyncService.Create(c.Request.Context(), orgID, userID, &in) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionCreate, models.AuditEntityLeadSyncSource, &src.ID, nil, map[string]string{"sheet": src.SheetTitle}) c.JSON(http.StatusCreated, src) } // GetLeadSyncSource returns one saved source. func (h *Handler) GetLeadSyncSource(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 } src, xerr := h.LeadSyncService.Get(c.Request.Context(), orgID, id) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, src) } // UpdateLeadSyncSource edits a saved source. func (h *Handler) UpdateLeadSyncSource(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 } var in models.UpdateLeadSyncSource if berr := c.ShouldBindJSON(&in); berr != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) return } src, xerr := h.LeadSyncService.Update(c.Request.Context(), orgID, id, &in) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityLeadSyncSource, &id, nil, nil) c.JSON(http.StatusOK, src) } // DeleteLeadSyncSource removes a saved source. func (h *Handler) DeleteLeadSyncSource(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 } if xerr := h.LeadSyncService.Delete(c.Request.Context(), orgID, id); xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionDelete, models.AuditEntityLeadSyncSource, &id, nil, nil) c.Status(http.StatusNoContent) } // SyncLeadSyncSourceNow runs the source on-demand: read the sheet -> upsert // contacts via ImportCommit. Naturally idempotent (email upsert), so retries // are safe without an Idempotency-Key. func (h *Handler) SyncLeadSyncSourceNow(c *gin.Context) { orgID, userID, ok := h.requireLeadSyncActor(c) if !ok { return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) return } result, xerr := h.LeadSyncService.SyncNow(c.Request.Context(), userID, orgID, id) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionImport, models.AuditEntityLeadSyncSource, &id, nil, map[string]string{"trigger": "manual_sync"}) c.JSON(http.StatusOK, result) }