Files
warmbly/internal/app/segment/service.go
T

502 lines
17 KiB
Go

// Package segment manages saved contact audiences; membership is computed at
// read time. The one exception is campaign links: a segment attached to a
// campaign enrols its members as leads, kept current by targeted syncs on
// every membership-changing write plus a periodic sweep.
package segment
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// CampaignWaker wakes a campaign's parked send chain after leads are added.
type CampaignWaker interface {
WakeCampaigns(ctx context.Context, orgID uuid.UUID, campaignIDs []string)
}
// CampaignStarter restarts a completed campaign whose linked segments grew.
// Satisfied structurally by campaign.CampaignService.
type CampaignStarter interface {
StartCampaign(ctx context.Context, orgID uuid.UUID, campaignID string, opts models.StartCampaignOptions) *errx.Error
}
type Service interface {
List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error)
Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error)
Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error)
Update(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error)
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
Preview(ctx context.Context, orgID uuid.UUID, in *models.SegmentPreview) (int, *errx.Error)
SetMembers(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentMembersWrite) (int, *errx.Error)
MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error)
AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, id uuid.UUID, in *models.SegmentAddToCampaign) (*models.SegmentAddToCampaignResult, *errx.Error)
// Fields describes every filterable field for the condition builder.
Fields(ctx context.Context, orgID uuid.UUID) ([]models.SegmentFieldSpec, *errx.Error)
SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error)
Overrides(ctx context.Context, orgID, id uuid.UUID) ([]models.SegmentOverride, *errx.Error)
// ListCampaignSegments lists the segments linked to a campaign.
ListCampaignSegments(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignSegmentLink, *errx.Error)
// SetCampaignSegments replaces a campaign's linked segments and enrols
// their current members immediately. Returns the links and how many
// leads were new.
SetCampaignSegments(ctx context.Context, orgID, campaignID uuid.UUID, in *models.CampaignSegmentsWrite) ([]models.CampaignSegmentLink, int, *errx.Error)
// SyncOrgLinkedCampaigns re-enrols every linked campaign of the org after
// contacts changed. Best effort: the sweep is the backstop.
SyncOrgLinkedCampaigns(ctx context.Context, orgID uuid.UUID)
// StartCampaignSegmentSync sweeps every linked campaign on an interval so
// membership drift (dates, engagement, nested segments) still enrols.
StartCampaignSegmentSync(ctx context.Context, interval time.Duration)
SetCampaignWaker(w CampaignWaker)
SetCampaignStarter(st CampaignStarter)
}
// CustomFieldLister is the slice of the contact repository Fields needs.
type CustomFieldLister interface {
DistinctCustomFieldKeys(ctx context.Context, orgID uuid.UUID) ([]string, error)
}
type service struct {
repo repository.SegmentRepository
fields CustomFieldLister
waker CampaignWaker
starter CampaignStarter
// orgSyncInFlight dedupes concurrent org-wide enrolment passes, keyed by
// org id.
orgSyncInFlight sync.Map
}
func NewService(repo repository.SegmentRepository, fields CustomFieldLister) Service {
return &service{repo: repo, fields: fields}
}
func (s *service) SetCampaignWaker(w CampaignWaker) { s.waker = w }
func (s *service) SetCampaignStarter(st CampaignStarter) { s.starter = st }
func (s *service) List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error) {
return s.repo.List(ctx, orgID)
}
func (s *service) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error) {
return s.repo.Get(ctx, orgID, id)
}
// applyWrite folds a create/update body into seg, validating each field it
// sets. selfID guards self-reference on update.
func applyWrite(seg *models.Segment, in *models.SegmentWrite, selfID *uuid.UUID) *errx.Error {
if in.Name != nil {
name, xerr := models.ValidateSegmentName(*in.Name)
if xerr != nil {
return xerr
}
seg.Name = name
}
if in.Description != nil {
d := strings.TrimSpace(*in.Description)
if len(d) > models.SegmentMaxDescLen {
return errx.New(errx.BadRequest, fmt.Sprintf("description must be at most %d characters", models.SegmentMaxDescLen))
}
seg.Description = d
}
if in.Color != nil {
color, xerr := models.ValidateSegmentColor(*in.Color)
if xerr != nil {
return xerr
}
seg.Color = color
}
if in.Match != nil {
if xerr := models.ValidateSegmentMatch(*in.Match); xerr != nil {
return xerr
}
seg.Match = *in.Match
}
if in.Conditions != nil {
conds := *in.Conditions
if conds == nil {
conds = []models.SegmentCondition{}
}
if xerr := models.ValidateSegmentConditions(conds, selfID); xerr != nil {
return xerr
}
seg.Conditions = conds
}
return nil
}
// checkReferences makes sure every referenced segment exists in the org and
// that following references from it never leads back to selfID.
func (s *service) checkReferences(ctx context.Context, orgID uuid.UUID, conds []models.SegmentCondition, selfID *uuid.UUID) *errx.Error {
refs := models.SegmentReferences(conds)
if len(refs) == 0 {
return nil
}
seen := map[uuid.UUID]bool{}
pending := refs
for depth := 0; len(pending) > 0; depth++ {
if depth > models.SegmentMaxNestingDeep {
return errx.New(errx.BadRequest, fmt.Sprintf("segments can be nested at most %d levels deep", models.SegmentMaxNestingDeep))
}
var next []uuid.UUID
for _, id := range pending {
if selfID != nil && id == *selfID {
return errx.New(errx.BadRequest, "segments cannot reference each other in a loop")
}
if seen[id] {
continue
}
seen[id] = true
ref, xerr := s.repo.Get(ctx, orgID, id)
if xerr != nil {
if xerr.Code == errx.NotFound {
return errx.New(errx.BadRequest, "a referenced segment does not exist")
}
return xerr
}
next = append(next, models.SegmentReferences(ref.Conditions)...)
}
pending = next
}
return nil
}
func (s *service) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error) {
seg := &models.Segment{Color: "#0284c7", Match: models.SegmentMatchAll, Conditions: []models.SegmentCondition{}}
if in.Name == nil {
return nil, errx.New(errx.BadRequest, "segment name is required")
}
if xerr := applyWrite(seg, in, nil); xerr != nil {
return nil, xerr
}
if xerr := s.checkReferences(ctx, orgID, seg.Conditions, nil); xerr != nil {
return nil, xerr
}
return s.repo.Create(ctx, orgID, createdBy, seg)
}
func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentWrite) (*models.Segment, *errx.Error) {
seg, xerr := s.repo.Get(ctx, orgID, id)
if xerr != nil {
return nil, xerr
}
if xerr := applyWrite(seg, in, &id); xerr != nil {
return nil, xerr
}
if in.Conditions != nil {
if xerr := s.checkReferences(ctx, orgID, seg.Conditions, &id); xerr != nil {
return nil, xerr
}
}
out, xerr := s.repo.Update(ctx, orgID, seg)
if xerr != nil {
return nil, xerr
}
// A definition change can admit new members; enrol them into linked
// campaigns right away instead of waiting for the sweep.
if in.Conditions != nil || in.Match != nil {
s.syncLinkedCampaignsForSegments(ctx, orgID, []uuid.UUID{id})
}
return out, nil
}
func (s *service) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
names, xerr := s.repo.ReferencedBy(ctx, orgID, id)
if xerr != nil {
return xerr
}
if len(names) > 0 {
return errx.New(errx.Conflict, "this segment is used by: "+strings.Join(names, ", ")+". Remove it from those segments first")
}
campaigns, xerr := s.repo.CampaignsUsingSegment(ctx, orgID, id)
if xerr != nil {
return xerr
}
if len(campaigns) > 0 {
return errx.New(errx.Conflict, "this segment is linked to: "+strings.Join(campaigns, ", ")+". Detach it from those campaigns first")
}
return s.repo.Delete(ctx, orgID, id)
}
func (s *service) Preview(ctx context.Context, orgID uuid.UUID, in *models.SegmentPreview) (int, *errx.Error) {
if in.Match == "" {
in.Match = models.SegmentMatchAll
}
if xerr := models.ValidateSegmentMatch(in.Match); xerr != nil {
return 0, xerr
}
if in.Conditions == nil {
in.Conditions = []models.SegmentCondition{}
}
if xerr := models.ValidateSegmentConditions(in.Conditions, in.ID); xerr != nil {
return 0, xerr
}
if xerr := s.checkReferences(ctx, orgID, in.Conditions, in.ID); xerr != nil {
return 0, xerr
}
if in.ID != nil {
if _, xerr := s.repo.Get(ctx, orgID, *in.ID); xerr != nil {
return 0, xerr
}
}
return s.repo.Count(ctx, orgID, in.ID, in.Match, in.Conditions)
}
func parseContactIDs(raw []string) ([]uuid.UUID, *errx.Error) {
if len(raw) == 0 {
return nil, errx.New(errx.BadRequest, "no contacts provided")
}
if len(raw) > 1000 {
return nil, errx.New(errx.BadRequest, "at most 1000 contacts per request")
}
out := make([]uuid.UUID, 0, len(raw))
for _, r := range raw {
id, err := uuid.Parse(r)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid contact id")
}
out = append(out, id)
}
return out, nil
}
func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *models.SegmentMembersWrite) (int, *errx.Error) {
switch in.Mode {
case models.SegmentMemberInclude, models.SegmentMemberExclude, models.SegmentMemberAuto:
default:
return 0, errx.New(errx.BadRequest, "mode must be include, exclude or auto")
}
ids, xerr := parseContactIDs(in.Contacts)
if xerr != nil {
return 0, xerr
}
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return 0, xerr
}
n, xerr := s.repo.SetMembers(ctx, orgID, id, ids, in.Mode)
if xerr != nil {
return 0, xerr
}
// A pin-in (or a cleared exclude) can admit new members.
if in.Mode != models.SegmentMemberExclude {
s.syncLinkedCampaignsForSegments(ctx, orgID, []uuid.UUID{id})
}
return n, nil
}
func (s *service) MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) {
ids, xerr := parseContactIDs(contactIDs)
if xerr != nil {
return nil, xerr
}
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return nil, xerr
}
return s.repo.MemberModes(ctx, id, ids)
}
func (s *service) AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, id uuid.UUID, in *models.SegmentAddToCampaign) (*models.SegmentAddToCampaignResult, *errx.Error) {
campaignID, err := uuid.Parse(in.CampaignID)
if err != nil {
return nil, errx.New(errx.BadRequest, "invalid campaign id")
}
res, xerr := s.repo.AddToCampaign(ctx, orgID, actor, id, campaignID)
if xerr != nil {
return nil, xerr
}
if s.waker != nil && res.Added > 0 {
s.waker.WakeCampaigns(ctx, orgID, []string{campaignID.String()})
}
return res, nil
}
func (s *service) ListCampaignSegments(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignSegmentLink, *errx.Error) {
return s.repo.ListForCampaign(ctx, orgID, campaignID)
}
func (s *service) SetCampaignSegments(ctx context.Context, orgID, campaignID uuid.UUID, in *models.CampaignSegmentsWrite) ([]models.CampaignSegmentLink, int, *errx.Error) {
seen := map[uuid.UUID]bool{}
ids := make([]uuid.UUID, 0, len(in.SegmentIDs))
for _, raw := range in.SegmentIDs {
id, err := uuid.Parse(raw)
if err != nil {
return nil, 0, errx.New(errx.BadRequest, "invalid segment id")
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) > models.CampaignSegmentsMax {
return nil, 0, errx.New(errx.BadRequest, fmt.Sprintf("a campaign can link at most %d segments", models.CampaignSegmentsMax))
}
if xerr := s.repo.SetForCampaign(ctx, orgID, campaignID, ids); xerr != nil {
return nil, 0, xerr
}
added := 0
if len(ids) > 0 {
links, xerr := s.repo.LinkedCampaignsForSegments(ctx, orgID, ids)
if xerr != nil {
return nil, 0, xerr
}
for _, lc := range links {
if lc.CampaignID == campaignID {
added = s.syncLinkedCampaign(ctx, lc)
}
}
}
out, xerr := s.repo.ListForCampaign(ctx, orgID, campaignID)
if xerr != nil {
return nil, 0, xerr
}
return out, added, nil
}
// syncLinkedCampaign enrols missing leads for one linked campaign, waking an
// active chain and restarting a completed one when anything was added.
func (s *service) syncLinkedCampaign(ctx context.Context, lc models.LinkedCampaign) int {
added, xerr := s.repo.SyncCampaignSegments(ctx, lc.OrganizationID, lc.CampaignID)
if xerr != nil {
log.Warn().Str("campaign_id", lc.CampaignID.String()).Str("error", xerr.Message).Msg("segment sync: enrol failed")
return 0
}
if added == 0 {
return 0
}
switch lc.Status {
case "active":
if s.waker != nil {
s.waker.WakeCampaigns(ctx, lc.OrganizationID, []string{lc.CampaignID.String()})
}
case "completed":
// Completed only means the campaign ran out of leads; new segment
// members are exactly the reason to pick it back up. StartCampaign
// re-runs every launch check, so a campaign past its end date, over
// plan limits, or with a risky list stays closed.
if s.starter != nil {
if xerr := s.starter.StartCampaign(ctx, lc.OrganizationID, lc.CampaignID.String(), models.StartCampaignOptions{Automatic: true}); xerr != nil {
log.Info().Str("campaign_id", lc.CampaignID.String()).Str("reason", xerr.Message).Msg("segment sync: completed campaign not restarted")
}
}
}
return added
}
// syncLinkedCampaignsForSegments re-enrols the campaigns linked to any of the
// given segments. Nested references (a linked segment built on this one) are
// not chased here; the periodic sweep covers them. Detached from the caller's
// request: the enrolment scans grow with the contact list, and this is
// declared best effort with the sweep as the backstop.
func (s *service) syncLinkedCampaignsForSegments(ctx context.Context, orgID uuid.UUID, segmentIDs []uuid.UUID) {
bg := context.WithoutCancel(ctx)
go func() {
rctx, cancel := context.WithTimeout(bg, 2*time.Minute)
defer cancel()
links, xerr := s.repo.LinkedCampaignsForSegments(rctx, orgID, segmentIDs)
if xerr != nil {
return
}
for _, lc := range links {
s.syncLinkedCampaign(rctx, lc)
}
}()
}
func (s *service) SyncOrgLinkedCampaigns(ctx context.Context, orgID uuid.UUID) {
// One in-flight pass per org: every contact write calls this, and a burst
// of writes must not stack org-wide enrolment scans. A write landing while
// a pass runs is picked up by the sweep within its interval.
if _, running := s.orgSyncInFlight.LoadOrStore(orgID, struct{}{}); running {
return
}
bg := context.WithoutCancel(ctx)
go func() {
defer s.orgSyncInFlight.Delete(orgID)
rctx, cancel := context.WithTimeout(bg, 2*time.Minute)
defer cancel()
links, xerr := s.repo.LinkedCampaigns(rctx, &orgID)
if xerr != nil {
return
}
for _, lc := range links {
s.syncLinkedCampaign(rctx, lc)
}
}()
}
func (s *service) StartCampaignSegmentSync(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Sweep once on boot so links catch up promptly after a restart.
s.sweepLinkedCampaigns(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweepLinkedCampaigns(ctx)
}
}
}
func (s *service) sweepLinkedCampaigns(ctx context.Context) {
scanCtx, scanCancel := context.WithTimeout(ctx, 30*time.Second)
links, xerr := s.repo.LinkedCampaigns(scanCtx, nil)
scanCancel()
if xerr != nil {
log.Warn().Str("error", xerr.Message).Msg("segment sync: sweep scan failed")
return
}
total := 0
// Each campaign gets its own deadline: one shared budget would starve the
// same tail campaigns every pass once the instance holds enough links.
for _, lc := range links {
if ctx.Err() != nil {
return
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
total += s.syncLinkedCampaign(cctx, lc)
cancel()
}
if total > 0 {
log.Info().Int("added", total).Msg("segment sync: sweep enrolled new leads")
}
}
func (s *service) Fields(ctx context.Context, orgID uuid.UUID) ([]models.SegmentFieldSpec, *errx.Error) {
out := make([]models.SegmentFieldSpec, 0, len(models.SegmentFieldCatalog)+16)
out = append(out, models.SegmentFieldCatalog...)
if s.fields == nil {
return out, nil
}
keys, err := s.fields.DistinctCustomFieldKeys(ctx, orgID)
if err != nil {
return nil, errx.InternalError()
}
for _, k := range keys {
out = append(out, models.SegmentFieldSpec{Field: models.SegmentCustomFieldPrefix + k, Label: k, Group: "Custom field", Kind: models.SegmentFieldText})
}
return out, nil
}
func (s *service) SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error) {
return s.repo.SegmentsForContact(ctx, orgID, contactID)
}
func (s *service) Overrides(ctx context.Context, orgID, id uuid.UUID) ([]models.SegmentOverride, *errx.Error) {
if _, xerr := s.repo.Get(ctx, orgID, id); xerr != nil {
return nil, xerr
}
return s.repo.ListOverrides(ctx, orgID, id)
}