mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 16:02:42 +00:00
352 lines
10 KiB
Go
352 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/apollo"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// apolloReady resolves the org, the actor and the connection id, or answers
|
|
// the request itself.
|
|
func (h *Handler) apolloReady(c *gin.Context) (orgID, userID, connID uuid.UUID, ok bool) {
|
|
if h.ApolloService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "Apollo is not available on this instance"))
|
|
return uuid.Nil, uuid.Nil, uuid.Nil, false
|
|
}
|
|
orgID, userID, ok = h.requireIntegrationActor(c, false)
|
|
if !ok {
|
|
return uuid.Nil, uuid.Nil, uuid.Nil, false
|
|
}
|
|
connID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid connection id"))
|
|
return uuid.Nil, uuid.Nil, uuid.Nil, false
|
|
}
|
|
return orgID, userID, connID, true
|
|
}
|
|
|
|
// apolloFail answers an Apollo error: the service's own refusals as they are,
|
|
// anything else as an internal error with the cause logged.
|
|
func apolloFail(c *gin.Context, err error, what string) {
|
|
var xe *errx.Error
|
|
if errors.As(err, &xe) {
|
|
errx.JSON(c, xe)
|
|
return
|
|
}
|
|
log.Error().Err(err).Str("op", what).Msg("apollo request failed")
|
|
errx.JSON(c, errx.InternalError())
|
|
}
|
|
|
|
// apolloSource loads an import source and checks it belongs to the connection.
|
|
func (h *Handler) apolloSource(c *gin.Context, orgID, connID uuid.UUID) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param("sourceId"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid import source id"))
|
|
return uuid.Nil, false
|
|
}
|
|
src, err := h.ApolloService.Repo.GetSource(c.Request.Context(), orgID, id)
|
|
if err != nil || src == nil || src.ConnectionID != connID {
|
|
errx.JSON(c, errx.New(errx.NotFound, "import source not found"))
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// ApolloOverview is the Apollo page header.
|
|
func (h *Handler) ApolloOverview(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, err := h.ApolloService.Overview(c.Request.Context(), orgID, connID)
|
|
if err != nil {
|
|
apolloFail(c, err, "overview")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ReplaceApolloKey checks a new API key and swaps it in. A PUT, so a retry
|
|
// converges on the same key.
|
|
func (h *Handler) ReplaceApolloKey(c *gin.Context) {
|
|
orgID, userID, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, err := h.ApolloService.ReplaceKey(c.Request.Context(), orgID, connID, in.APIKey)
|
|
if err != nil {
|
|
apolloFail(c, err, "replace key")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "apollo:key")
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ApolloLists returns the account's saved contact lists.
|
|
func (h *Handler) ApolloLists(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, err := h.ApolloService.Lists(c.Request.Context(), orgID, connID)
|
|
if err != nil {
|
|
apolloFail(c, err, "lists")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
// PreviewApolloImport reads the first people of a list. Read-only, so retries
|
|
// are naturally safe.
|
|
func (h *Handler) PreviewApolloImport(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
ListID string `json:"list_id"`
|
|
VerifiedOnly *bool `json:"verified_only"`
|
|
}
|
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, err := h.ApolloService.Preview(c.Request.Context(), orgID, connID, in.ListID, in.VerifiedOnly == nil || *in.VerifiedOnly)
|
|
if err != nil {
|
|
apolloFail(c, err, "preview")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// ListApolloImportSources lists a connection's saved list imports.
|
|
func (h *Handler) ListApolloImportSources(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, err := h.ApolloService.Repo.ListSources(c.Request.Context(), orgID, connID)
|
|
if err != nil {
|
|
apolloFail(c, err, "list sources")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
// CreateApolloImportSource saves a list import and starts its first run. A
|
|
// retried create makes a second source whose run imports nothing new: people
|
|
// are deduplicated by address, so the retry is harmless.
|
|
func (h *Handler) CreateApolloImportSource(c *gin.Context) {
|
|
orgID, userID, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in apollo.SourceInput
|
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
out, err := h.ApolloService.CreateSource(c.Request.Context(), orgID, connID, userID, in)
|
|
if err != nil {
|
|
apolloFail(c, err, "create source")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionCreate, connID, "apollo:import:"+out.ID.String())
|
|
c.JSON(http.StatusCreated, out)
|
|
}
|
|
|
|
// UpdateApolloImportSource edits a list import's targets, filter and schedule.
|
|
func (h *Handler) UpdateApolloImportSource(c *gin.Context) {
|
|
orgID, userID, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := h.apolloSource(c, orgID, connID)
|
|
if !ok {
|
|
return
|
|
}
|
|
raw, err := c.GetRawData()
|
|
if err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
var in apollo.SourceInput
|
|
if err := json.Unmarshal(raw, &in); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
// campaign_id: null clears the target; absent leaves it.
|
|
var probe map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &probe) == nil {
|
|
if v, ok := probe["campaign_id"]; ok && strings.TrimSpace(string(v)) == "null" {
|
|
in.ClearCampaign = true
|
|
}
|
|
}
|
|
out, err := h.ApolloService.UpdateSource(c.Request.Context(), orgID, id, in)
|
|
if err != nil {
|
|
apolloFail(c, err, "update source")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "apollo:import:"+id.String())
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// RunApolloImportSource runs a list import now. A second request while one
|
|
// runs is refused with 409, so retries cannot double-run it.
|
|
func (h *Handler) RunApolloImportSource(c *gin.Context) {
|
|
orgID, userID, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := h.apolloSource(c, orgID, connID)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, err := h.ApolloService.StartRun(c.Request.Context(), orgID, id)
|
|
if err != nil {
|
|
apolloFail(c, err, "run source")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "apollo:import-run:"+id.String())
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// DeleteApolloImportSource removes a list import; contacts it brought in stay.
|
|
func (h *Handler) DeleteApolloImportSource(c *gin.Context) {
|
|
orgID, userID, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := h.apolloSource(c, orgID, connID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.ApolloService.Repo.DeleteSource(c.Request.Context(), orgID, id); err != nil {
|
|
apolloFail(c, err, "delete source")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionDelete, connID, "apollo:import:"+id.String())
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ListApolloEnrichments lists a connection's recent enrichment runs.
|
|
func (h *Handler) ListApolloEnrichments(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := 20
|
|
if v := c.Query("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 || n > 100 {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "limit must be between 1 and 100"))
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
out, err := h.ApolloService.Repo.ListEnrichmentRuns(c.Request.Context(), orgID, connID, limit)
|
|
if err != nil {
|
|
apolloFail(c, err, "list enrichments")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
// GetApolloEnrichment returns one enrichment run's progress.
|
|
func (h *Handler) GetApolloEnrichment(c *gin.Context) {
|
|
orgID, _, connID, ok := h.apolloReady(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("runId"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid enrichment run id"))
|
|
return
|
|
}
|
|
run, err := h.ApolloService.Repo.GetEnrichmentRun(c.Request.Context(), orgID, id)
|
|
if err != nil {
|
|
apolloFail(c, err, "get enrichment")
|
|
return
|
|
}
|
|
if run == nil || run.ConnectionID != connID {
|
|
errx.JSON(c, errx.New(errx.NotFound, "enrichment run not found"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, run)
|
|
}
|
|
|
|
type apolloEnrichPayload struct {
|
|
models.ContactSelection
|
|
ContactIDs []string `json:"contact_ids"`
|
|
Overwrite bool `json:"overwrite"`
|
|
}
|
|
|
|
// StartApolloEnrichment queues an enrichment run over a contact selection.
|
|
// Supports Idempotency-Key through the API middleware; without one a retry
|
|
// queues a second run, which only rewrites the same fields again.
|
|
func (h *Handler) StartApolloEnrichment(c *gin.Context) {
|
|
if h.ApolloService == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "Apollo is not available on this instance"))
|
|
return
|
|
}
|
|
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 connection id"))
|
|
return
|
|
}
|
|
var p apolloEnrichPayload
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
sel := p.ContactSelection
|
|
if !sel.All && len(sel.Contacts) == 0 {
|
|
sel.Contacts = p.ContactIDs
|
|
}
|
|
resolved, ok := h.resolveContactSelection(c, orgID, sel)
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(resolved) > apollo.EnrichCap {
|
|
errx.JSON(c, errx.NewWithIdentifier(errx.BadRequest, "apollo_enrich_too_many", "Enrich at most 5,000 contacts at a time"))
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, 0, len(resolved))
|
|
for _, raw := range resolved {
|
|
id, perr := uuid.Parse(strings.TrimSpace(raw))
|
|
if perr != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid contact id: "+raw))
|
|
return
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
run, err := h.ApolloService.StartEnrichment(c.Request.Context(), orgID, connID, userID, ids, p.Overwrite)
|
|
if err != nil {
|
|
apolloFail(c, err, "start enrichment")
|
|
return
|
|
}
|
|
h.auditIntegration(c, userID, models.AuditActionCreate, connID, "apollo:enrich:"+run.ID.String())
|
|
c.JSON(http.StatusAccepted, run)
|
|
}
|