Files
warmbly/internal/api/handler/apollo.go
T

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)
}