Files
warmbly/internal/models/segment.go
T

439 lines
16 KiB
Go

package models
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/utils"
)
// Segment is a saved, reusable audience: a list of conditions over contacts
// plus per-contact manual overrides. Membership is evaluated at read time.
type Segment struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Match SegmentMatch `json:"match"`
Conditions []SegmentCondition `json:"conditions"`
// ContactCount is the live membership size; IncludedCount and
// ExcludedCount are the manual overrides. Populated by reads.
ContactCount int `json:"contact_count"`
IncludedCount int `json:"included_count"`
ExcludedCount int `json:"excluded_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SegmentMatch says whether every condition or any condition must hold.
type SegmentMatch string
const (
SegmentMatchAll SegmentMatch = "all"
SegmentMatchAny SegmentMatch = "any"
)
// SegmentMemberMode is a manual override on one contact.
type SegmentMemberMode string
const (
SegmentMemberInclude SegmentMemberMode = "include"
SegmentMemberExclude SegmentMemberMode = "exclude"
// SegmentMemberAuto clears the override so the conditions decide again.
SegmentMemberAuto SegmentMemberMode = "auto"
)
// SegmentCondition is one predicate. Field picks the column or derived value,
// Operator the comparison; scalar operators read Value, list operators read
// Values. Custom fields are addressed as "custom.<key>".
type SegmentCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value,omitempty"`
Values []string `json:"values,omitempty"`
}
// SegmentFieldKind groups fields by the operators they accept.
type SegmentFieldKind string
const (
SegmentFieldText SegmentFieldKind = "text"
SegmentFieldEnum SegmentFieldKind = "enum"
SegmentFieldBool SegmentFieldKind = "bool"
SegmentFieldDate SegmentFieldKind = "date"
SegmentFieldNumber SegmentFieldKind = "number"
SegmentFieldCategory SegmentFieldKind = "category"
SegmentFieldCampaign SegmentFieldKind = "campaign"
SegmentFieldSegment SegmentFieldKind = "segment"
)
// Segment condition operators.
const (
SegOpEquals = "equals"
SegOpNotEquals = "not_equals"
SegOpContains = "contains"
SegOpNotContains = "not_contains"
SegOpStartsWith = "starts_with"
SegOpEndsWith = "ends_with"
SegOpIsEmpty = "is_empty"
SegOpIsNotEmpty = "is_not_empty"
SegOpIn = "in"
SegOpNotIn = "not_in"
SegOpIsTrue = "is_true"
SegOpIsFalse = "is_false"
SegOpBefore = "before"
SegOpAfter = "after"
SegOpWithinDays = "within_days"
SegOpNotWithinDays = "not_within_days"
SegOpGT = "gt"
SegOpGTE = "gte"
SegOpLT = "lt"
SegOpLTE = "lte"
)
// SegmentFieldSpec describes one filterable field for validation and for the
// dashboard's condition builder (GET /segments/fields).
type SegmentFieldSpec struct {
Field string `json:"field"`
Label string `json:"label"`
Group string `json:"group"`
Kind SegmentFieldKind `json:"kind"`
// Options lists the accepted values of an enum field.
Options []string `json:"options,omitempty"`
}
// SegmentFieldCatalog is every non-custom field a condition may name.
var SegmentFieldCatalog = []SegmentFieldSpec{
{Field: "first_name", Label: "First name", Group: "Contact", Kind: SegmentFieldText},
{Field: "last_name", Label: "Last name", Group: "Contact", Kind: SegmentFieldText},
{Field: "email", Label: "Email", Group: "Contact", Kind: SegmentFieldText},
{Field: "email_domain", Label: "Email domain", Group: "Contact", Kind: SegmentFieldText},
{Field: "phone", Label: "Phone", Group: "Contact", Kind: SegmentFieldText},
{Field: "subscribed", Label: "Subscribed", Group: "Contact", Kind: SegmentFieldBool},
{Field: "suppressed", Label: "On the suppression list", Group: "Contact", Kind: SegmentFieldBool},
{Field: "source", Label: "Source", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"unknown", "manual", "campaign", "import", "sheet_sync", "api", "ai_assistant", "form"}},
{Field: "verification_status", Label: "Verification status", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"valid", "risky", "invalid", "unknown"}},
{Field: "is_catch_all", Label: "Catch-all domain", Group: "Contact", Kind: SegmentFieldBool},
{Field: "esp_provider", Label: "Email provider", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"gmail", "outlook", "other"}},
{Field: "created_at", Label: "Created", Group: "Contact", Kind: SegmentFieldDate},
{Field: "updated_at", Label: "Updated", Group: "Contact", Kind: SegmentFieldDate},
{Field: "category", Label: "Category", Group: "Contact", Kind: SegmentFieldCategory},
{Field: "company", Label: "Company name", Group: "Company", Kind: SegmentFieldText},
{Field: "campaign", Label: "In campaign", Group: "Campaign activity", Kind: SegmentFieldCampaign},
{Field: "campaign_count", Label: "Number of campaigns", Group: "Campaign activity", Kind: SegmentFieldNumber},
{Field: "emails_sent", Label: "Emails sent", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_opened", Label: "Emails opened", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_clicked", Label: "Links clicked", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_replied", Label: "Replies", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "emails_bounced", Label: "Bounces", Group: "Email engagement", Kind: SegmentFieldNumber},
{Field: "last_sent_at", Label: "Last email sent", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_opened_at", Label: "Last open", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_clicked_at", Label: "Last click", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "last_replied_at", Label: "Last reply", Group: "Email engagement", Kind: SegmentFieldDate},
{Field: "segment", Label: "In segment", Group: "Segments", Kind: SegmentFieldSegment},
}
// SegmentCustomFieldPrefix addresses a contact custom field: "custom.industry".
const SegmentCustomFieldPrefix = "custom."
// Segment validation limits.
const (
SegmentMaxConditions = 50
SegmentMaxListValues = 200
SegmentMaxNameLen = 120
SegmentMaxDescLen = 1000
SegmentMaxValueLen = 500
SegmentMaxNestingDeep = 5
SegmentsPerOrgMax = 200
)
var segmentColorRe = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
// SegmentFieldSpecFor resolves a condition's field to its spec. Custom fields
// resolve to a synthetic text spec.
func SegmentFieldSpecFor(field string) (SegmentFieldSpec, bool) {
if strings.HasPrefix(field, SegmentCustomFieldPrefix) {
key := utils.NormalizeJSONKey(strings.TrimPrefix(field, SegmentCustomFieldPrefix))
if key == "" || !utils.IsValidJSONKey(key) {
return SegmentFieldSpec{}, false
}
return SegmentFieldSpec{Field: SegmentCustomFieldPrefix + key, Label: key, Group: "Custom field", Kind: SegmentFieldText}, true
}
for _, s := range SegmentFieldCatalog {
if s.Field == field {
return s, true
}
}
return SegmentFieldSpec{}, false
}
// OperatorsForKind lists the operators a field kind accepts.
func OperatorsForKind(kind SegmentFieldKind) []string {
switch kind {
case SegmentFieldText:
return []string{SegOpEquals, SegOpNotEquals, SegOpContains, SegOpNotContains, SegOpStartsWith, SegOpEndsWith, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldEnum:
return []string{SegOpIn, SegOpNotIn}
case SegmentFieldBool:
return []string{SegOpIsTrue, SegOpIsFalse}
case SegmentFieldDate:
return []string{SegOpBefore, SegOpAfter, SegOpWithinDays, SegOpNotWithinDays, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldNumber:
return []string{SegOpEquals, SegOpNotEquals, SegOpGT, SegOpGTE, SegOpLT, SegOpLTE}
case SegmentFieldCategory, SegmentFieldCampaign:
return []string{SegOpIn, SegOpNotIn, SegOpIsEmpty, SegOpIsNotEmpty}
case SegmentFieldSegment:
return []string{SegOpIn, SegOpNotIn}
}
return nil
}
// SegmentWrite is the create/update body.
type SegmentWrite struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Color *string `json:"color,omitempty"`
Match *SegmentMatch `json:"match,omitempty"`
Conditions *[]SegmentCondition `json:"conditions,omitempty"`
}
// SegmentPreview is the body of POST /segments/preview: an unsaved definition
// to count. ID, when set, keeps that segment's manual overrides in the count.
type SegmentPreview struct {
ID *uuid.UUID `json:"id,omitempty"`
Match SegmentMatch `json:"match"`
Conditions []SegmentCondition `json:"conditions"`
}
// SegmentMembersWrite sets a manual override on a batch of contacts.
type SegmentMembersWrite struct {
Contacts []string `json:"contacts"`
Mode SegmentMemberMode `json:"mode"`
}
// SegmentAddToCampaign enrols the segment's current members as leads.
type SegmentAddToCampaign struct {
CampaignID string `json:"campaign_id"`
}
// SegmentAddToCampaignResult reports how many leads were actually new.
type SegmentAddToCampaignResult struct {
CampaignID uuid.UUID `json:"campaign_id"`
Added int `json:"added"`
Members int `json:"members"`
// Status is the campaign's status at enrol time, for the wake/restart decision.
Status string `json:"-"`
}
// CampaignSegmentsMax bounds how many segments one campaign can link.
const CampaignSegmentsMax = 20
// CampaignSegmentsWrite replaces a campaign's linked segments.
type CampaignSegmentsWrite struct {
SegmentIDs []string `json:"segment_ids"`
}
// CampaignSegmentLink is one segment linked to a campaign, for the Leads tab;
// the counts are live: members now, members that are leads, members held out.
type CampaignSegmentLink struct {
SegmentID uuid.UUID `json:"segment_id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
ContactCount int `json:"contact_count"`
LeadCount int `json:"lead_count"`
HeldOutCount int `json:"held_out_count"`
LinkedAt time.Time `json:"linked_at"`
}
// LinkedCampaign is one campaign that has segments attached, for the sync
// sweep and the targeted per-segment syncs.
type LinkedCampaign struct {
CampaignID uuid.UUID
OrganizationID uuid.UUID
Status string
}
// ContactSegment is one segment a contact belongs to, with its override.
type ContactSegment struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
// Mode is "include" or "exclude" when the contact carries a manual
// override, empty when the conditions alone decide.
Mode SegmentMemberMode `json:"mode,omitempty"`
// Member is whether the contact is currently in the segment.
Member bool `json:"member"`
}
// SegmentOverride is one manually included or excluded contact.
type SegmentOverride struct {
ContactID uuid.UUID `json:"contact_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Company string `json:"company"`
Mode SegmentMemberMode `json:"mode"`
CreatedAt time.Time `json:"created_at"`
}
// SegmentOverridesMax bounds one overrides listing.
const SegmentOverridesMax = 500
// ValidateSegmentName trims and bounds the name.
func ValidateSegmentName(name string) (string, *errx.Error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errx.New(errx.BadRequest, "segment name is required")
}
if len(name) > SegmentMaxNameLen {
return "", errx.New(errx.BadRequest, fmt.Sprintf("segment name must be at most %d characters", SegmentMaxNameLen))
}
return name, nil
}
// ValidateSegmentColor accepts a #rrggbb color.
func ValidateSegmentColor(color string) (string, *errx.Error) {
color = strings.ToLower(strings.TrimSpace(color))
if !segmentColorRe.MatchString(color) {
return "", errx.New(errx.BadRequest, "color must be a #rrggbb value")
}
return color, nil
}
// ValidateSegmentMatch accepts all|any.
func ValidateSegmentMatch(m SegmentMatch) *errx.Error {
if m != SegmentMatchAll && m != SegmentMatchAny {
return errx.New(errx.BadRequest, "match must be all or any")
}
return nil
}
// ValidateSegmentConditions normalizes every condition in place and rejects
// anything the SQL builder would not know how to compile. selfID, when set,
// refuses a segment that references itself.
func ValidateSegmentConditions(conds []SegmentCondition, selfID *uuid.UUID) *errx.Error {
if len(conds) > SegmentMaxConditions {
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d conditions per segment", SegmentMaxConditions))
}
for i := range conds {
c := &conds[i]
c.Field = strings.TrimSpace(c.Field)
c.Operator = strings.TrimSpace(c.Operator)
spec, ok := SegmentFieldSpecFor(c.Field)
if !ok {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: unknown field %q", i+1, c.Field))
}
c.Field = spec.Field
if !containsString(OperatorsForKind(spec.Kind), c.Operator) {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: operator %q is not valid for %s", i+1, c.Operator, spec.Label))
}
if len(c.Value) > SegmentMaxValueLen {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value too long", i+1))
}
if len(c.Values) > SegmentMaxListValues {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: at most %d values", i+1, SegmentMaxListValues))
}
switch c.Operator {
case SegOpIsEmpty, SegOpIsNotEmpty, SegOpIsTrue, SegOpIsFalse:
c.Value, c.Values = "", nil
continue
case SegOpIn, SegOpNotIn:
c.Value = ""
if len(c.Values) == 0 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: pick at least one value", i+1))
}
default:
c.Values = nil
if strings.TrimSpace(c.Value) == "" {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: a value is required", i+1))
}
}
switch spec.Kind {
case SegmentFieldEnum:
for _, v := range c.Values {
if !containsString(spec.Options, v) {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: %q is not a valid %s", i+1, v, spec.Label))
}
}
case SegmentFieldCategory, SegmentFieldCampaign, SegmentFieldSegment:
for _, v := range c.Values {
id, err := uuid.Parse(v)
if err != nil {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: %q is not a valid id", i+1, v))
}
if spec.Kind == SegmentFieldSegment && selfID != nil && id == *selfID {
return errx.New(errx.BadRequest, "a segment cannot reference itself")
}
}
case SegmentFieldNumber:
n, err := strconv.Atoi(strings.TrimSpace(c.Value))
if err != nil || n < 0 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value must be a whole number", i+1))
}
c.Value = strconv.Itoa(n)
case SegmentFieldDate:
switch c.Operator {
case SegOpWithinDays, SegOpNotWithinDays:
n, err := strconv.Atoi(strings.TrimSpace(c.Value))
if err != nil || n < 1 || n > 3650 {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: days must be between 1 and 3650", i+1))
}
c.Value = strconv.Itoa(n)
default:
t, err := parseSegmentDate(c.Value)
if err != nil {
return errx.New(errx.BadRequest, fmt.Sprintf("condition %d: value must be a date (YYYY-MM-DD)", i+1))
}
c.Value = t.UTC().Format(time.RFC3339)
}
}
}
return nil
}
// SegmentReferences lists the other segments the conditions depend on.
func SegmentReferences(conds []SegmentCondition) []uuid.UUID {
var out []uuid.UUID
for _, c := range conds {
if c.Field != "segment" {
continue
}
for _, v := range c.Values {
if id, err := uuid.Parse(v); err == nil {
out = append(out, id)
}
}
}
return out
}
func parseSegmentDate(v string) (time.Time, error) {
v = strings.TrimSpace(v)
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
return time.Parse("2006-01-02", v)
}
func containsString(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}