From 3391f6a4ec50745eac315f7474e12fa6628bdc80 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 01/22] feat: add the segments and segment_members tables (migration 000110) and register both in the workspace export/import spec under the Contacts group --- internal/app/orgtransfer/spec.go | 9 +++++ .../db/migrations/000110_segments.down.sql | 2 + .../db/migrations/000110_segments.up.sql | 38 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 internal/infrastructure/db/migrations/000110_segments.down.sql create mode 100644 internal/infrastructure/db/migrations/000110_segments.up.sql diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index cbc1c9b8..fba07c3f 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -257,6 +257,15 @@ var Tables = []Table{ Name: "contact_notes", Group: models.OrgDataGroupContacts, Scope: scopeOrg, }, + { + Name: "segments", Group: models.OrgDataGroupContacts, + Scope: scopeOrg, + Note: "Conditions travel as written; ones naming a campaign or category still match once that group arrives.", + }, + { + Name: "segment_members", Group: models.OrgDataGroupContacts, + Scope: `segment_id IN (SELECT id FROM segments WHERE organization_id = $1)`, + }, { Name: "contact_activities", Group: models.OrgDataGroupContacts, Scope: scopeOrg, diff --git a/internal/infrastructure/db/migrations/000110_segments.down.sql b/internal/infrastructure/db/migrations/000110_segments.down.sql new file mode 100644 index 00000000..63a2fb08 --- /dev/null +++ b/internal/infrastructure/db/migrations/000110_segments.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.segment_members; +DROP TABLE IF EXISTS public.segments; diff --git a/internal/infrastructure/db/migrations/000110_segments.up.sql b/internal/infrastructure/db/migrations/000110_segments.up.sql new file mode 100644 index 00000000..839751b6 --- /dev/null +++ b/internal/infrastructure/db/migrations/000110_segments.up.sql @@ -0,0 +1,38 @@ +-- Contact segments (issue #266): saved, reusable audiences. A segment is a +-- filter tree over contacts (properties, categories, campaign activity, +-- engagement, other segments) plus per-contact manual overrides. Membership +-- is evaluated at read time, so it is always current and never needs a +-- recompute job; only the definition and the overrides are stored. +CREATE TABLE public.segments ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + created_by uuid REFERENCES public.users(id) ON DELETE SET NULL, + name text NOT NULL, + description text NOT NULL DEFAULT '', + color character varying(7) NOT NULL DEFAULT '#0284c7', + match text NOT NULL DEFAULT 'all', + conditions jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT segments_color_check CHECK (color ~* '^#[a-f0-9]{6}$'), + CONSTRAINT segments_match_check CHECK (match IN ('all', 'any')), + CONSTRAINT segments_conditions_check CHECK (jsonb_typeof(conditions) = 'array') +); + +CREATE UNIQUE INDEX segments_org_name_unique ON public.segments (organization_id, lower(name)); + +COMMENT ON COLUMN public.segments.conditions IS + 'Filter list, validated at the app boundary (models.SegmentCondition); match says whether all or any must hold.'; + +-- Manual overrides: an included contact is a member whether or not it matches +-- the conditions, an excluded one is never a member even when it does. +CREATE TABLE public.segment_members ( + segment_id uuid NOT NULL REFERENCES public.segments(id) ON DELETE CASCADE, + contact_id uuid NOT NULL REFERENCES public.contacts(id) ON DELETE CASCADE, + mode text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (segment_id, contact_id), + CONSTRAINT segment_members_mode_check CHECK (mode IN ('include', 'exclude')) +); + +CREATE INDEX idx_segment_members_contact ON public.segment_members (contact_id); From fd80c5613618f2394bef1bc108655cd0df535228 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 02/22] feat: add the segment model with the typed condition DSL, the filterable field catalog, operator sets per field kind, validation and normalization of every condition, and the segment audit entity type --- internal/models/audit.go | 1 + internal/models/segment.go | 407 ++++++++++++++++++++++++++++++++ internal/models/segment_test.go | 79 +++++++ 3 files changed, 487 insertions(+) create mode 100644 internal/models/segment.go create mode 100644 internal/models/segment_test.go diff --git a/internal/models/audit.go b/internal/models/audit.go index 1f1c7be6..3fe28e05 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -76,6 +76,7 @@ const ( AuditEntityFolder AuditEntityType = "folder" AuditEntityTag AuditEntityType = "tag" AuditEntityCategory AuditEntityType = "category" + AuditEntitySegment AuditEntityType = "segment" AuditEntitySubscription AuditEntityType = "subscription" AuditEntitySettings AuditEntityType = "settings" diff --git a/internal/models/segment.go b/internal/models/segment.go new file mode 100644 index 00000000..363a88ae --- /dev/null +++ b/internal/models/segment.go @@ -0,0 +1,407 @@ +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.". +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"}}, + {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"` +} + +// 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 +} diff --git a/internal/models/segment_test.go b/internal/models/segment_test.go new file mode 100644 index 00000000..ee58e31f --- /dev/null +++ b/internal/models/segment_test.go @@ -0,0 +1,79 @@ +package models + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestValidateSegmentConditionsNormalizes(t *testing.T) { + self := uuid.New() + conds := []SegmentCondition{ + {Field: " company ", Operator: "contains", Value: "acme"}, + {Field: "custom.Job Title", Operator: "is_empty", Value: "junk", Values: []string{"x"}}, + {Field: "emails_opened", Operator: "gte", Value: " 3 "}, + {Field: "created_at", Operator: "after", Value: "2026-01-15"}, + {Field: "last_replied_at", Operator: "within_days", Value: "30"}, + {Field: "source", Operator: "in", Values: []string{"import", "api"}}, + {Field: "category", Operator: "in", Values: []string{uuid.New().String()}}, + } + if err := ValidateSegmentConditions(conds, &self); err != nil { + t.Fatalf("valid conditions rejected: %v", err) + } + if conds[0].Field != "company" { + t.Errorf("field not trimmed: %q", conds[0].Field) + } + if conds[1].Value != "" || conds[1].Values != nil { + t.Errorf("valueless operator kept values: %+v", conds[1]) + } + if conds[2].Value != "3" { + t.Errorf("number not normalized: %q", conds[2].Value) + } + if conds[3].Value != "2026-01-15T00:00:00Z" { + t.Errorf("date not normalized: %q", conds[3].Value) + } +} + +func TestValidateSegmentConditionsRejects(t *testing.T) { + self := uuid.New() + bad := []struct { + name string + cond SegmentCondition + }{ + {"unknown field", SegmentCondition{Field: "nope", Operator: "equals", Value: "x"}}, + {"wrong operator", SegmentCondition{Field: "subscribed", Operator: "contains", Value: "x"}}, + {"missing value", SegmentCondition{Field: "email", Operator: "equals"}}, + {"empty list", SegmentCondition{Field: "category", Operator: "in"}}, + {"bad enum", SegmentCondition{Field: "source", Operator: "in", Values: []string{"martian"}}}, + {"bad uuid", SegmentCondition{Field: "campaign", Operator: "in", Values: []string{"abc"}}}, + {"negative number", SegmentCondition{Field: "emails_sent", Operator: "gt", Value: "-1"}}, + {"days out of range", SegmentCondition{Field: "created_at", Operator: "within_days", Value: "0"}}, + {"bad date", SegmentCondition{Field: "updated_at", Operator: "before", Value: "yesterday"}}, + {"self reference", SegmentCondition{Field: "segment", Operator: "in", Values: []string{self.String()}}}, + {"bad custom key", SegmentCondition{Field: "custom.", Operator: "equals", Value: "x"}}, + } + for _, tc := range bad { + if err := ValidateSegmentConditions([]SegmentCondition{tc.cond}, &self); err == nil { + t.Errorf("%s: accepted", tc.name) + } + } + many := make([]SegmentCondition, SegmentMaxConditions+1) + for i := range many { + many[i] = SegmentCondition{Field: "email", Operator: "is_not_empty"} + } + if err := ValidateSegmentConditions(many, nil); err == nil { + t.Errorf("over the condition cap accepted") + } +} + +func TestSegmentReferences(t *testing.T) { + a, b := uuid.New(), uuid.New() + refs := SegmentReferences([]SegmentCondition{ + {Field: "segment", Operator: "in", Values: []string{a.String(), "junk"}}, + {Field: "category", Operator: "in", Values: []string{b.String()}}, + {Field: "segment", Operator: "not_in", Values: []string{b.String()}}, + }) + if len(refs) != 2 || refs[0] != a || refs[1] != b { + t.Fatalf("refs = %v", refs) + } +} From 0f6680754632b9a5e68879d0ac13dac78950cdb4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 03/22] feat: compile a segment definition into a bound-parameter WHERE fragment over contacts, covering text, enum, bool, date, number, category, campaign and nested-segment conditions plus manual include/exclude overrides, with cycle and depth guards --- internal/repository/pg_segment_sql.go | 329 ++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 internal/repository/pg_segment_sql.go diff --git a/internal/repository/pg_segment_sql.go b/internal/repository/pg_segment_sql.go new file mode 100644 index 00000000..6a10ad47 --- /dev/null +++ b/internal/repository/pg_segment_sql.go @@ -0,0 +1,329 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/models" +) + +// segmentQuerier is the subset of pgx both the pool and a transaction satisfy. +type segmentQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// segmentDef is the part of a segment the predicate compiler needs. +type segmentDef struct { + ID uuid.UUID + Match models.SegmentMatch + Conditions []models.SegmentCondition +} + +// segmentBuilder compiles segment definitions into a WHERE fragment over the +// `contacts c` alias. Values are always bound, never interpolated; the only +// strings that reach the SQL text are column names picked from a fixed map. +type segmentBuilder struct { + orgID uuid.UUID + args []any + graph map[uuid.UUID]*segmentDef +} + +func (b *segmentBuilder) bind(v any) string { + b.args = append(b.args, v) + return fmt.Sprintf("$%d", len(b.args)) +} + +// loadSegmentGraph fetches the referenced segments transitively, stopping at +// SegmentMaxNestingDeep hops: anything deeper compiles to FALSE. +func loadSegmentGraph(ctx context.Context, q segmentQuerier, orgID uuid.UUID, roots []uuid.UUID) (map[uuid.UUID]*segmentDef, error) { + graph := map[uuid.UUID]*segmentDef{} + pending := roots + for depth := 0; depth <= models.SegmentMaxNestingDeep && len(pending) > 0; depth++ { + var want []uuid.UUID + for _, id := range pending { + if _, ok := graph[id]; !ok { + want = append(want, id) + } + } + if len(want) == 0 { + break + } + rows, err := q.Query(ctx, `SELECT id, match, conditions FROM segments WHERE organization_id = $1 AND id = ANY($2::uuid[])`, orgID, want) + if err != nil { + return nil, err + } + var next []uuid.UUID + for rows.Next() { + var d segmentDef + var raw []byte + if err := rows.Scan(&d.ID, &d.Match, &raw); err != nil { + rows.Close() + return nil, err + } + if err := json.Unmarshal(raw, &d.Conditions); err != nil { + rows.Close() + return nil, err + } + graph[d.ID] = &d + next = append(next, models.SegmentReferences(d.Conditions)...) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + pending = next + } + return graph, nil +} + +// segmentClause compiles one segment (saved or preview) to a predicate. +// withOverrides folds the manual include/exclude rows in when the segment has +// an id. visited guards reference cycles: a loop compiles to FALSE. +func (b *segmentBuilder) segmentClause(def *segmentDef, withOverrides bool, visited map[uuid.UUID]bool) string { + if def.ID != uuid.Nil { + if visited[def.ID] { + return "FALSE" + } + visited[def.ID] = true + defer delete(visited, def.ID) + } + dyn := "FALSE" + if len(def.Conditions) > 0 { + parts := make([]string, 0, len(def.Conditions)) + for _, c := range def.Conditions { + parts = append(parts, b.condition(c, visited)) + } + joiner := " AND " + if def.Match == models.SegmentMatchAny { + joiner = " OR " + } + dyn = "(" + strings.Join(parts, joiner) + ")" + } + if !withOverrides || def.ID == uuid.Nil { + return dyn + } + id := b.bind(def.ID) + return fmt.Sprintf( + "((%s OR c.id IN (SELECT sm.contact_id FROM segment_members sm WHERE sm.segment_id = %s AND sm.mode = 'include')) "+ + "AND c.id NOT IN (SELECT sm.contact_id FROM segment_members sm WHERE sm.segment_id = %s AND sm.mode = 'exclude'))", + dyn, id, id) +} + +var segmentTextColumns = map[string]string{ + "first_name": "c.first_name", + "last_name": "c.last_name", + "email": "c.email", + "email_domain": "split_part(c.email, '@', 2)", + "phone": "c.phone", + "company": "c.company", +} + +var segmentEnumColumns = map[string]string{ + "source": "c.source", + "verification_status": "c.verification_status", + "esp_provider": "c.esp_provider", +} + +var segmentDateExprs = map[string]string{ + "created_at": "c.created_at", + "updated_at": "c.updated_at", + "last_sent_at": "(SELECT MAX(p.sent_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)", + "last_opened_at": "(SELECT MAX(p.opened_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND NOT p.opened_machine)", + "last_clicked_at": "(SELECT MAX(p.clicked_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)", + "last_replied_at": "(SELECT MAX(p.replied_at) FROM campaign_contact_progress p WHERE p.contact_id = c.id)", +} + +var segmentNumberExprs = map[string]string{ + "campaign_count": "(SELECT COUNT(*) FROM campaign_leads cl WHERE cl.contact_id = c.id)", + "emails_sent": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.sent_at IS NOT NULL)", + "emails_opened": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.opened_at IS NOT NULL AND NOT p.opened_machine)", + "emails_clicked": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.clicked_at IS NOT NULL)", + "emails_replied": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.replied_at IS NOT NULL)", + "emails_bounced": "(SELECT COUNT(*) FROM campaign_contact_progress p WHERE p.contact_id = c.id AND p.bounced_at IS NOT NULL)", +} + +// segmentInt and segmentTime re-parse validated values so the bound parameter +// carries the Postgres type the cast expects. +func segmentInt(v string) int { + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n +} + +func segmentTime(v string) time.Time { + t, err := time.Parse(time.RFC3339, strings.TrimSpace(v)) + if err != nil { + t, _ = time.Parse("2006-01-02", strings.TrimSpace(v)) + } + return t +} + +// escapeLike makes a user string safe inside an ILIKE pattern. +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +func (b *segmentBuilder) condition(c models.SegmentCondition, visited map[uuid.UUID]bool) string { + spec, ok := models.SegmentFieldSpecFor(c.Field) + if !ok { + return "FALSE" + } + switch spec.Kind { + case models.SegmentFieldText: + expr, ok := segmentTextColumns[spec.Field] + if !ok { + key := strings.TrimPrefix(spec.Field, models.SegmentCustomFieldPrefix) + expr = fmt.Sprintf("COALESCE(c.custom_fields ->> %s::text, '')", b.bind(key)) + } + return b.textOp(expr, c) + case models.SegmentFieldEnum: + expr := segmentEnumColumns[spec.Field] + list := b.bind(c.Values) + if c.Operator == models.SegOpNotIn { + return fmt.Sprintf("NOT (%s = ANY(%s::text[]))", expr, list) + } + return fmt.Sprintf("%s = ANY(%s::text[])", expr, list) + case models.SegmentFieldBool: + var expr string + switch spec.Field { + case "subscribed": + expr = "c.subscribed" + case "is_catch_all": + expr = "c.is_catch_all" + case "suppressed": + expr = fmt.Sprintf("EXISTS (SELECT 1 FROM suppressed_recipients sr WHERE sr.organization_id = %s AND lower(sr.email) = lower(c.email) AND (sr.expires_at IS NULL OR sr.expires_at > now()))", b.bind(b.orgID)) + default: + return "FALSE" + } + if c.Operator == models.SegOpIsFalse { + return "NOT " + expr + } + return expr + case models.SegmentFieldDate: + expr := segmentDateExprs[spec.Field] + switch c.Operator { + case models.SegOpBefore: + return fmt.Sprintf("%s < %s::timestamptz", expr, b.bind(segmentTime(c.Value))) + case models.SegOpAfter: + return fmt.Sprintf("%s > %s::timestamptz", expr, b.bind(segmentTime(c.Value))) + case models.SegOpWithinDays: + return fmt.Sprintf("%s >= now() - (%s::int * interval '1 day')", expr, b.bind(segmentInt(c.Value))) + case models.SegOpNotWithinDays: + return fmt.Sprintf("(%[1]s IS NULL OR %[1]s < now() - (%[2]s::int * interval '1 day'))", expr, b.bind(segmentInt(c.Value))) + case models.SegOpIsEmpty: + return fmt.Sprintf("%s IS NULL", expr) + case models.SegOpIsNotEmpty: + return fmt.Sprintf("%s IS NOT NULL", expr) + } + case models.SegmentFieldNumber: + expr := segmentNumberExprs[spec.Field] + ops := map[string]string{ + models.SegOpEquals: "=", models.SegOpNotEquals: "<>", + models.SegOpGT: ">", models.SegOpGTE: ">=", models.SegOpLT: "<", models.SegOpLTE: "<=", + } + if op, ok := ops[c.Operator]; ok { + return fmt.Sprintf("%s %s %s::bigint", expr, op, b.bind(segmentInt(c.Value))) + } + case models.SegmentFieldCategory, models.SegmentFieldCampaign: + table, col := "contact_categories", "category_id" + if spec.Kind == models.SegmentFieldCampaign { + table, col = "campaign_leads", "campaign_id" + } + switch c.Operator { + case models.SegOpIn: + return fmt.Sprintf("EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id AND x.%s = ANY(%s::uuid[]))", table, col, b.bind(c.Values)) + case models.SegOpNotIn: + return fmt.Sprintf("NOT EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id AND x.%s = ANY(%s::uuid[]))", table, col, b.bind(c.Values)) + case models.SegOpIsEmpty: + return fmt.Sprintf("NOT EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id)", table) + case models.SegOpIsNotEmpty: + return fmt.Sprintf("EXISTS (SELECT 1 FROM %s x WHERE x.contact_id = c.id)", table) + } + case models.SegmentFieldSegment: + parts := make([]string, 0, len(c.Values)) + for _, v := range c.Values { + id, err := uuid.Parse(v) + if err != nil { + continue + } + def, ok := b.graph[id] + if !ok { + parts = append(parts, "FALSE") + continue + } + parts = append(parts, b.segmentClause(def, true, visited)) + } + if len(parts) == 0 { + return "FALSE" + } + anyOf := "(" + strings.Join(parts, " OR ") + ")" + if c.Operator == models.SegOpNotIn { + return "NOT " + anyOf + } + return anyOf + } + return "FALSE" +} + +func (b *segmentBuilder) textOp(expr string, c models.SegmentCondition) string { + switch c.Operator { + case models.SegOpEquals: + return fmt.Sprintf("lower(%s) = lower(%s)", expr, b.bind(c.Value)) + case models.SegOpNotEquals: + return fmt.Sprintf("lower(%s) <> lower(%s)", expr, b.bind(c.Value)) + case models.SegOpContains: + return fmt.Sprintf("%s ILIKE %s", expr, b.bind("%"+escapeLike(c.Value)+"%")) + case models.SegOpNotContains: + return fmt.Sprintf("%s NOT ILIKE %s", expr, b.bind("%"+escapeLike(c.Value)+"%")) + case models.SegOpStartsWith: + return fmt.Sprintf("%s ILIKE %s", expr, b.bind(escapeLike(c.Value)+"%")) + case models.SegOpEndsWith: + return fmt.Sprintf("%s ILIKE %s", expr, b.bind("%"+escapeLike(c.Value))) + case models.SegOpIsEmpty: + return fmt.Sprintf("COALESCE(%s, '') = ''", expr) + case models.SegOpIsNotEmpty: + return fmt.Sprintf("COALESCE(%s, '') <> ''", expr) + } + return "FALSE" +} + +// compileSegment returns the membership predicate for a saved segment or an +// unsaved preview, with `args` extended by whatever it bound. Callers append +// the clause to a query whose parameter list is exactly `args`. +func compileSegment(ctx context.Context, q segmentQuerier, orgID uuid.UUID, def *segmentDef, args []any) (string, []any, error) { + roots := models.SegmentReferences(def.Conditions) + if def.ID != uuid.Nil { + roots = append(roots, def.ID) + } + graph, err := loadSegmentGraph(ctx, q, orgID, roots) + if err != nil { + return "", args, err + } + b := &segmentBuilder{orgID: orgID, args: args, graph: graph} + visited := map[uuid.UUID]bool{} + clause := b.segmentClause(def, true, visited) + return clause, b.args, nil +} + +// compileSavedSegment loads a segment by id and compiles it. An unknown id +// compiles to FALSE, so a stale filter matches nothing rather than erroring. +func compileSavedSegment(ctx context.Context, q segmentQuerier, orgID, segmentID uuid.UUID, args []any) (string, []any, error) { + graph, err := loadSegmentGraph(ctx, q, orgID, []uuid.UUID{segmentID}) + if err != nil { + return "", args, err + } + def, ok := graph[segmentID] + if !ok { + return "FALSE", args, nil + } + b := &segmentBuilder{orgID: orgID, args: args, graph: graph} + visited := map[uuid.UUID]bool{} + clause := b.segmentClause(def, true, visited) + return clause, b.args, nil +} From 2308bd814a356529daaca4eaf1f8d3fa8eac5ad5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 04/22] feat: add the segment repository: CRUD with per-org name uniqueness and cap, live member counts, preview count, manual override writes and lookups, reference detection, contact-side membership view, overrides listing and one-step campaign enrolment that logs activities --- internal/repository/pg_segment.go | 404 ++++++++++++++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 internal/repository/pg_segment.go diff --git a/internal/repository/pg_segment.go b/internal/repository/pg_segment.go new file mode 100644 index 00000000..5abbb733 --- /dev/null +++ b/internal/repository/pg_segment.go @@ -0,0 +1,404 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" +) + +type SegmentRepository 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, seg *models.Segment) (*models.Segment, *errx.Error) + Update(ctx context.Context, orgID uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error) + Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error + // ReferencedBy names the segments whose conditions point at id. + ReferencedBy(ctx context.Context, orgID, id uuid.UUID) ([]string, *errx.Error) + // Count evaluates a definition (saved or not) against the org's contacts. + Count(ctx context.Context, orgID uuid.UUID, id *uuid.UUID, match models.SegmentMatch, conds []models.SegmentCondition) (int, *errx.Error) + // SetMembers writes a manual override for each contact; Auto removes it. + SetMembers(ctx context.Context, orgID, segmentID uuid.UUID, contactIDs []uuid.UUID, mode models.SegmentMemberMode) (int, *errx.Error) + // MemberModes reports the manual override of each listed contact. + MemberModes(ctx context.Context, segmentID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) + // AddToCampaign enrols every current member of the segment as a lead. + AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, segmentID, campaignID uuid.UUID) (*models.SegmentAddToCampaignResult, *errx.Error) + // SegmentsForContact evaluates every segment of the org for one contact. + SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error) + // ListOverrides lists the manually included and excluded contacts. + ListOverrides(ctx context.Context, orgID, segmentID uuid.UUID) ([]models.SegmentOverride, *errx.Error) +} + +type segmentRepository struct { + DB *db.DB +} + +func NewSegmentRepository(d *db.DB) SegmentRepository { + return &segmentRepository{DB: d} +} + +const segmentColumns = `s.id, s.organization_id, s.created_by, s.name, s.description, s.color, s.match, s.conditions, + (SELECT COUNT(*) FROM segment_members sm WHERE sm.segment_id = s.id AND sm.mode = 'include'), + (SELECT COUNT(*) FROM segment_members sm WHERE sm.segment_id = s.id AND sm.mode = 'exclude'), + s.created_at, s.updated_at` + +func scanSegment(row pgx.Row) (*models.Segment, error) { + var s models.Segment + var raw []byte + if err := row.Scan(&s.ID, &s.OrganizationID, &s.CreatedBy, &s.Name, &s.Description, &s.Color, &s.Match, &raw, + &s.IncludedCount, &s.ExcludedCount, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + s.Conditions = []models.SegmentCondition{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &s.Conditions); err != nil { + return nil, err + } + } + return &s, nil +} + +func (r *segmentRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.Segment, *errx.Error) { + rows, err := r.DB.Query(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 ORDER BY lower(s.name) ASC`, orgID) + if err != nil { + db.CaptureError(err, "segments list", nil, "query") + return nil, errx.InternalError() + } + defer rows.Close() + out := []models.Segment{} + for rows.Next() { + s, err := scanSegment(rows) + if err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + out = append(out, *s) + } + for i := range out { + n, xerr := r.Count(ctx, orgID, &out[i].ID, out[i].Match, out[i].Conditions) + if xerr != nil { + return nil, xerr + } + out[i].ContactCount = n + } + return out, nil +} + +func (r *segmentRepository) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Segment, *errx.Error) { + s, err := scanSegment(r.DB.QueryRow(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 AND s.id = $2`, orgID, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errx.New(errx.NotFound, "segment not found") + } + db.CaptureError(err, "segments get", nil, "queryrow") + return nil, errx.InternalError() + } + n, xerr := r.Count(ctx, orgID, &s.ID, s.Match, s.Conditions) + if xerr != nil { + return nil, xerr + } + s.ContactCount = n + return s, nil +} + +func (r *segmentRepository) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error) { + var total int + if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM segments WHERE organization_id = $1`, orgID).Scan(&total); err != nil { + db.CaptureError(err, "segments count", nil, "queryrow") + return nil, errx.InternalError() + } + if total >= models.SegmentsPerOrgMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("a workspace can have at most %d segments", models.SegmentsPerOrgMax)) + } + conds, _ := json.Marshal(seg.Conditions) + var id uuid.UUID + err := r.DB.QueryRow(ctx, ` + INSERT INTO segments (organization_id, created_by, name, description, color, match, conditions) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id`, orgID, createdBy, seg.Name, seg.Description, seg.Color, seg.Match, conds).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return nil, errx.New(errx.Conflict, "a segment with that name already exists") + } + db.CaptureError(err, "segments insert", nil, "queryrow") + return nil, errx.InternalError() + } + return r.Get(ctx, orgID, id) +} + +func (r *segmentRepository) Update(ctx context.Context, orgID uuid.UUID, seg *models.Segment) (*models.Segment, *errx.Error) { + conds, _ := json.Marshal(seg.Conditions) + tag, err := r.DB.Exec(ctx, ` + UPDATE segments SET name = $3, description = $4, color = $5, match = $6, conditions = $7, updated_at = now() + WHERE organization_id = $1 AND id = $2`, orgID, seg.ID, seg.Name, seg.Description, seg.Color, seg.Match, conds) + if err != nil { + if isUniqueViolation(err) { + return nil, errx.New(errx.Conflict, "a segment with that name already exists") + } + db.CaptureError(err, "segments update", nil, "exec") + return nil, errx.InternalError() + } + if tag.RowsAffected() == 0 { + return nil, errx.New(errx.NotFound, "segment not found") + } + return r.Get(ctx, orgID, seg.ID) +} + +func (r *segmentRepository) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error { + tag, err := r.DB.Exec(ctx, `DELETE FROM segments WHERE organization_id = $1 AND id = $2`, orgID, id) + if err != nil { + db.CaptureError(err, "segments delete", nil, "exec") + return errx.InternalError() + } + if tag.RowsAffected() == 0 { + return errx.New(errx.NotFound, "segment not found") + } + return nil +} + +func (r *segmentRepository) ReferencedBy(ctx context.Context, orgID, id uuid.UUID) ([]string, *errx.Error) { + needle, _ := json.Marshal([]map[string]any{{"field": "segment", "values": []string{id.String()}}}) + rows, err := r.DB.Query(ctx, `SELECT name FROM segments WHERE organization_id = $1 AND id <> $2 AND conditions @> $3::jsonb ORDER BY lower(name)`, orgID, id, needle) + if err != nil { + db.CaptureError(err, "segments referenced", nil, "query") + return nil, errx.InternalError() + } + defer rows.Close() + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + names = append(names, n) + } + return names, nil +} + +func (r *segmentRepository) Count(ctx context.Context, orgID uuid.UUID, id *uuid.UUID, match models.SegmentMatch, conds []models.SegmentCondition) (int, *errx.Error) { + def := &segmentDef{Match: match, Conditions: conds} + if id != nil { + def.ID = *id + } + args := []any{orgID} + clause, args, err := compileSegment(ctx, r.DB, orgID, def, args) + if err != nil { + db.CaptureError(err, "segment compile", nil, "query") + return 0, errx.InternalError() + } + query := `SELECT COUNT(*) FROM contacts c WHERE c.organization_id = $1 AND (` + clause + `)` + var n int + if err := r.DB.QueryRow(ctx, query, args...).Scan(&n); err != nil { + db.CaptureError(err, query, args, "queryrow") + return 0, errx.InternalError() + } + return n, nil +} + +func (r *segmentRepository) SetMembers(ctx context.Context, orgID, segmentID uuid.UUID, contactIDs []uuid.UUID, mode models.SegmentMemberMode) (int, *errx.Error) { + var tag pgconn.CommandTag + var err error + if mode == models.SegmentMemberAuto { + tag, err = r.DB.Exec(ctx, ` + DELETE FROM segment_members sm USING segments s + WHERE sm.segment_id = s.id AND s.organization_id = $1 AND s.id = $2 AND sm.contact_id = ANY($3::uuid[])`, + orgID, segmentID, contactIDs) + } else { + tag, err = r.DB.Exec(ctx, ` + INSERT INTO segment_members (segment_id, contact_id, mode) + SELECT s.id, c.id, $4 + FROM segments s + JOIN contacts c ON c.organization_id = s.organization_id + WHERE s.organization_id = $1 AND s.id = $2 AND c.id = ANY($3::uuid[]) + ON CONFLICT (segment_id, contact_id) DO UPDATE SET mode = EXCLUDED.mode, created_at = now()`, + orgID, segmentID, contactIDs, string(mode)) + } + if err != nil { + db.CaptureError(err, "segment members", nil, "exec") + return 0, errx.InternalError() + } + return int(tag.RowsAffected()), nil +} + +func (r *segmentRepository) MemberModes(ctx context.Context, segmentID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) { + out := map[uuid.UUID]models.SegmentMemberMode{} + if len(contactIDs) == 0 { + return out, nil + } + rows, err := r.DB.Query(ctx, `SELECT contact_id, mode FROM segment_members WHERE segment_id = $1 AND contact_id = ANY($2::uuid[])`, segmentID, contactIDs) + if err != nil { + db.CaptureError(err, "segment member modes", nil, "query") + return nil, errx.InternalError() + } + defer rows.Close() + for rows.Next() { + var id uuid.UUID + var mode string + if err := rows.Scan(&id, &mode); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + out[id] = models.SegmentMemberMode(mode) + } + return out, nil +} + +func (r *segmentRepository) AddToCampaign(ctx context.Context, orgID uuid.UUID, actor string, segmentID, campaignID uuid.UUID) (*models.SegmentAddToCampaignResult, *errx.Error) { + tx, err := r.DB.Begin(ctx) + if err != nil { + db.CaptureError(err, "", nil, "begin") + return nil, errx.InternalError() + } + defer tx.Rollback(ctx) + + var exists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM campaigns WHERE id = $1 AND organization_id = $2)`, campaignID, orgID).Scan(&exists); err != nil { + db.CaptureError(err, "campaign exists", nil, "queryrow") + return nil, errx.InternalError() + } + if !exists { + return nil, errx.New(errx.NotFound, "campaign not found") + } + + args := []any{orgID} + clause, args, err := compileSavedSegment(ctx, tx, orgID, segmentID, args) + if err != nil { + db.CaptureError(err, "segment compile", nil, "query") + return nil, errx.InternalError() + } + if clause == "FALSE" { + return nil, errx.New(errx.NotFound, "segment not found") + } + + var members int + countQ := `SELECT COUNT(*) FROM contacts c WHERE c.organization_id = $1 AND (` + clause + `)` + if err := tx.QueryRow(ctx, countQ, args...).Scan(&members); err != nil { + db.CaptureError(err, countQ, args, "queryrow") + return nil, errx.InternalError() + } + + // The campaign is bound after the compiled clause so the count query + // above carries no unused parameter. + args = append(args, campaignID) + insertQ := fmt.Sprintf(`INSERT INTO campaign_leads (contact_id, campaign_id) + SELECT c.id, $%d::uuid FROM contacts c WHERE c.organization_id = $1 AND (%s) + ON CONFLICT DO NOTHING + RETURNING contact_id, campaign_id`, len(args), clause) + rows, err := tx.Query(ctx, insertQ, args...) + if err != nil { + db.CaptureError(err, insertQ, args, "query") + return nil, errx.InternalError() + } + links, err := collectLinkPairs(rows) + if err != nil { + db.CaptureError(err, insertQ, args, "returning") + return nil, errx.InternalError() + } + if err := logCampaignLinks(ctx, tx, orgID, actorID(actor), models.ActivityCampaignAdded, links); err != nil { + db.CaptureError(err, "", nil, "campaign_added activity") + return nil, errx.InternalError() + } + if err := tx.Commit(ctx); err != nil { + db.CaptureError(err, "", nil, "commit") + return nil, errx.InternalError() + } + return &models.SegmentAddToCampaignResult{CampaignID: campaignID, Added: len(links), Members: members}, nil +} + +func (r *segmentRepository) SegmentsForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactSegment, *errx.Error) { + rows, err := r.DB.Query(ctx, `SELECT `+segmentColumns+` FROM segments s WHERE s.organization_id = $1 ORDER BY lower(s.name) ASC`, orgID) + if err != nil { + db.CaptureError(err, "segments for contact", nil, "query") + return nil, errx.InternalError() + } + var defs []*models.Segment + for rows.Next() { + seg, err := scanSegment(rows) + if err != nil { + rows.Close() + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + defs = append(defs, seg) + } + rows.Close() + out := make([]models.ContactSegment, 0, len(defs)) + if len(defs) == 0 { + return out, nil + } + ids := make([]uuid.UUID, 0, len(defs)) + for _, d := range defs { + ids = append(ids, d.ID) + } + modes := map[uuid.UUID]models.SegmentMemberMode{} + mrows, err := r.DB.Query(ctx, `SELECT segment_id, mode FROM segment_members WHERE contact_id = $1 AND segment_id = ANY($2::uuid[])`, contactID, ids) + if err != nil { + db.CaptureError(err, "contact segment modes", nil, "query") + return nil, errx.InternalError() + } + for mrows.Next() { + var id uuid.UUID + var mode string + if err := mrows.Scan(&id, &mode); err != nil { + mrows.Close() + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + modes[id] = models.SegmentMemberMode(mode) + } + mrows.Close() + // One membership probe per segment: the compiled predicate over a single + // contact row, which is what the segment page would compute for it anyway. + for _, d := range defs { + args := []any{orgID, contactID} + clause, args, cerr := compileSavedSegment(ctx, r.DB, orgID, d.ID, args) + if cerr != nil { + db.CaptureError(cerr, "segment compile", nil, "query") + return nil, errx.InternalError() + } + var member bool + q := `SELECT EXISTS (SELECT 1 FROM contacts c WHERE c.organization_id = $1 AND c.id = $2 AND (` + clause + `))` + if err := r.DB.QueryRow(ctx, q, args...).Scan(&member); err != nil { + db.CaptureError(err, q, args, "queryrow") + return nil, errx.InternalError() + } + out = append(out, models.ContactSegment{ID: d.ID, Name: d.Name, Color: d.Color, Mode: modes[d.ID], Member: member}) + } + return out, nil +} + +func (r *segmentRepository) ListOverrides(ctx context.Context, orgID, segmentID uuid.UUID) ([]models.SegmentOverride, *errx.Error) { + rows, err := r.DB.Query(ctx, ` + SELECT c.id, c.first_name, c.last_name, c.email, c.company, sm.mode, sm.created_at + FROM segment_members sm + JOIN segments s ON s.id = sm.segment_id + JOIN contacts c ON c.id = sm.contact_id + WHERE s.organization_id = $1 AND s.id = $2 + ORDER BY sm.mode ASC, sm.created_at DESC + LIMIT $3`, orgID, segmentID, models.SegmentOverridesMax) + if err != nil { + db.CaptureError(err, "segment overrides", nil, "query") + return nil, errx.InternalError() + } + defer rows.Close() + out := []models.SegmentOverride{} + for rows.Next() { + var o models.SegmentOverride + var mode string + if err := rows.Scan(&o.ContactID, &o.FirstName, &o.LastName, &o.Email, &o.Company, &mode, &o.CreatedAt); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + o.Mode = models.SegmentMemberMode(mode) + out = append(out, o) + } + return out, nil +} From 1eb3dbe481588beddadc7ca54be0235cfb7d7c7a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 05/22] feat: let the contacts search and export accept segment_ids so any contact query can be scoped to a segment, and label add/remove-segment steps in the lead progress view --- internal/models/contact.go | 1 + internal/repository/pg_contact.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/models/contact.go b/internal/models/contact.go index 72556b61..7456d00a 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -451,6 +451,7 @@ type SearchContacts struct { LeadStatus string `json:"lead_status"` // Filter by derived lead status; requires exactly one campaign_id Engagement string `json:"engagement"` // Filter by lead engagement (opened, not_opened, ...); ANDed with lead_status; requires exactly one campaign_id CategoryIDs []string `json:"category_ids"` // Contacts must have ALL these categories + SegmentIDs []string `json:"segment_ids"` // Contacts must be members of ALL these segments MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns Subscribed *bool `json:"subscribed"` // Filter by subscription status diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 4003146d..6951ab22 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -850,6 +850,30 @@ func (r *contactRepository) Search( whereClauses = append(whereClauses, categoryClause) } + // ----------------------------- + // Segment membership (must be in ALL specified segments) + // ----------------------------- + // Each segment compiles to its own predicate; args grow in lockstep with + // argIndex, which the builder relies on. + for _, raw := range filters.SegmentIDs { + sid, err := uuid.Parse(raw) + if err != nil { + return nil, errx.New(errx.BadRequest, "invalid segment id") + } + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return nil, errx.New(errx.BadRequest, "invalid organization id") + } + clause, nextArgs, cerr := compileSavedSegment(ctx, r.DB, orgUUID, sid, args) + if cerr != nil { + db.CaptureError(cerr, "segment compile", nil, "query") + return nil, errx.InternalError() + } + args = nextArgs + argIndex = len(args) + 1 + whereClauses = append(whereClauses, "("+clause+")") + } + // ----------------------------- // Sort logic // ----------------------------- @@ -975,6 +999,8 @@ func (r *contactRepository) Search( WHEN s.kind = 'action' THEN (CASE s.action->>'type' WHEN 'add_tag' THEN 'Add tag' WHEN 'remove_tag' THEN 'Remove tag' + WHEN 'add_to_segment' THEN 'Add to segment' + WHEN 'remove_from_segment' THEN 'Remove from segment' WHEN 'unsubscribe' THEN 'Unsubscribe' WHEN 'notify' THEN 'Notify' ELSE 'Action' END) From 87b3a00aa2851feff9a03e2eecccf71b4859a4f8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 06/22] feat: add the segment service with create/update validation, nested reference and loop checks, delete refusal when another segment depends on it, preview, member overrides, campaign enrolment that wakes the campaign, and the field catalog with the org's custom fields --- internal/app/segment/service.go | 293 ++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 internal/app/segment/service.go diff --git a/internal/app/segment/service.go b/internal/app/segment/service.go new file mode 100644 index 00000000..8c6a5537 --- /dev/null +++ b/internal/app/segment/service.go @@ -0,0 +1,293 @@ +// Package segment manages saved contact audiences (issue #266). A segment is +// a filter definition plus manual overrides; membership is computed at read +// time by the repository's SQL compiler, so nothing here schedules work. +package segment + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "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) +} + +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) + SetCampaignWaker(w CampaignWaker) +} + +// 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 +} + +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) 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 + } + } + return s.repo.Update(ctx, orgID, seg) +} + +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") + } + 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 + } + return s.repo.SetMembers(ctx, orgID, id, ids, in.Mode) +} + +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) 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) +} From 14a21bf9bdb47c4d38bd88ea1312005df6c757e1 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 07/22] feat: expose the segments API (list, fields, preview, create, get, patch, delete, members, member lookup, overrides, add-to-campaign) and GET /contacts/:id/segments behind contact permissions, wire the service into the backend and audit each mutation on the realtime spine --- cmd/backend/main.go | 16 +++ internal/api/handler/handler.go | 2 + internal/api/handler/segment.go | 243 ++++++++++++++++++++++++++++++++ internal/api/routes.go | 19 +++ 4 files changed, 280 insertions(+) create mode 100644 internal/api/handler/segment.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index d8276c1a..a09b2d00 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -78,6 +78,7 @@ import ( "github.com/warmbly/warmbly/internal/app/releases" "github.com/warmbly/warmbly/internal/app/replyclassify" "github.com/warmbly/warmbly/internal/app/research" + "github.com/warmbly/warmbly/internal/app/segment" "github.com/warmbly/warmbly/internal/app/sequence" "github.com/warmbly/warmbly/internal/app/settings" "github.com/warmbly/warmbly/internal/app/skills" @@ -162,6 +163,7 @@ func main() { var rateLimitService ratelimit.RateLimitService var sequenceService sequence.SequenceService var contactService contact.ContactService + var segmentService segment.Service var websiteTrackingService websitetracking.Service var socketService socket.SocketService var uniboxService unibox.UniboxService @@ -1191,6 +1193,8 @@ func main() { rateLimitService = ratelimit.NewService(cache, rateLimitRepository) sequenceService = sequence.NewService(sequenceRepostory) contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher) + segmentRepository := repository.NewSegmentRepository(primaryDB) + segmentService = segment.NewService(segmentRepository, contactRepostory) // A visibly bad import is filed on the workspace's posture. On its own // it can only reach `watch`, which changes nothing. if aware, ok := contactService.(contact.OrgRiskAware); ok && orgRiskService != nil { @@ -1278,6 +1282,9 @@ func main() { // parked send chain, or the lead sits queued until the chain's next // tick. Wired here because contactService is built before the scheduler // and Cloud Tasks client exist. + if segmentService != nil { + segmentService.SetCampaignWaker(campaignService) + } if contactService != nil { contactService.SetCampaignWaker(campaignService) // The contact drawer's "next action" is a read-only pass through @@ -1480,6 +1487,14 @@ func main() { trackedLinkRepository, integrationServiceForHandler, // AutomationRunner for campaign run_automation steps ) + // Sequence action nodes that pin a contact into or out of a segment, + // both on the scheduled path (tasks) and the instant reply path (advanced). + if aware, ok := tasksService.(tasks.SegmentAware); ok { + aware.WireSegments(segmentRepository) + } + if aware, ok := advancedService.(advanced.SegmentAware); ok { + aware.WireSegments(segmentRepository) + } // A restricted organization warms in the free pool, whatever it pays. if aware, ok := tasksService.(tasks.OrgRiskAware); ok { aware.WireOrgRisk(orgRiskRepository) @@ -1791,6 +1806,7 @@ func main() { AnalyticsService: analyticsService, RateLimitService: rateLimitService, ContactService: contactService, + SegmentService: segmentService, SequenceService: sequenceService, UniboxService: uniboxService, diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 734785ba..bdf64c17 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -44,6 +44,7 @@ import ( "github.com/warmbly/warmbly/internal/app/referral" "github.com/warmbly/warmbly/internal/app/releases" "github.com/warmbly/warmbly/internal/app/research" + "github.com/warmbly/warmbly/internal/app/segment" "github.com/warmbly/warmbly/internal/app/sequence" "github.com/warmbly/warmbly/internal/app/skills" "github.com/warmbly/warmbly/internal/app/socket" @@ -99,6 +100,7 @@ type Handler struct { EmailService email.EmailService CampaignService campaign.CampaignService ContactService contact.ContactService + SegmentService segment.Service SequenceService sequence.SequenceService UniboxService unibox.UniboxService diff --git a/internal/api/handler/segment.go b/internal/api/handler/segment.go new file mode 100644 index 00000000..2908e99b --- /dev/null +++ b/internal/api/handler/segment.go @@ -0,0 +1,243 @@ +package handler + +import ( + "net/http" + + "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" +) + +// segmentScope pulls the org and the segment id (when the route has one). +func segmentScope(c *gin.Context) (uuid.UUID, uuid.UUID, bool) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return uuid.Nil, uuid.Nil, false + } + raw := c.Param("id") + if raw == "" { + return *orgID, uuid.Nil, true + } + id, err := uuid.Parse(raw) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid segment id")) + return uuid.Nil, uuid.Nil, false + } + return *orgID, id, true +} + +func (h *Handler) ListSegments(c *gin.Context) { + orgID, _, ok := segmentScope(c) + if !ok { + return + } + out, xerr := h.SegmentService.List(c.Request.Context(), orgID) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) ListSegmentFields(c *gin.Context) { + orgID, _, ok := segmentScope(c) + if !ok { + return + } + out, xerr := h.SegmentService.Fields(c.Request.Context(), orgID) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) GetSegment(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + out, xerr := h.SegmentService.Get(c.Request.Context(), orgID, id) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, out) +} + +func (h *Handler) CreateSegment(c *gin.Context) { + orgID, _, ok := segmentScope(c) + if !ok { + return + } + var in models.SegmentWrite + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + var createdBy *uuid.UUID + if uid, err := middleware.GetUserUUID(c); err == nil { + createdBy = &uid + } + out, xerr := h.SegmentService.Create(c.Request.Context(), orgID, createdBy, &in) + if xerr != nil { + errx.Handle(c, xerr) + return + } + h.auditOrg(c, models.AuditActionCreate, models.AuditEntitySegment, &out.ID, nil, map[string]string{"name": out.Name}) + c.JSON(http.StatusCreated, out) +} + +func (h *Handler) UpdateSegment(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + var in models.SegmentWrite + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + out, xerr := h.SegmentService.Update(c.Request.Context(), orgID, id, &in) + if xerr != nil { + errx.Handle(c, xerr) + return + } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySegment, &out.ID, nil, map[string]string{"name": out.Name}) + c.JSON(http.StatusOK, out) +} + +func (h *Handler) DeleteSegment(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + if xerr := h.SegmentService.Delete(c.Request.Context(), orgID, id); xerr != nil { + errx.Handle(c, xerr) + return + } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntitySegment, &id, nil, nil) + c.Status(http.StatusNoContent) +} + +// PreviewSegment counts the contacts an unsaved definition would match. +func (h *Handler) PreviewSegment(c *gin.Context) { + orgID, _, ok := segmentScope(c) + if !ok { + return + } + var in models.SegmentPreview + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + n, xerr := h.SegmentService.Preview(c.Request.Context(), orgID, &in) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"contact_count": n}) +} + +// SetSegmentMembers writes a manual include/exclude override (or clears it). +func (h *Handler) SetSegmentMembers(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + var in models.SegmentMembersWrite + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + n, xerr := h.SegmentService.SetMembers(c.Request.Context(), orgID, id, &in) + if xerr != nil { + errx.Handle(c, xerr) + return + } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySegment, &id, nil, map[string]string{"members": string(in.Mode)}) + c.JSON(http.StatusOK, gin.H{"updated": n}) +} + +// GetSegmentMemberModes reports the manual override of the given contacts. +func (h *Handler) GetSegmentMemberModes(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + var in struct { + Contacts []string `json:"contacts"` + } + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + modes, xerr := h.SegmentService.MemberModes(c.Request.Context(), orgID, id, in.Contacts) + if xerr != nil { + errx.Handle(c, xerr) + return + } + out := map[string]models.SegmentMemberMode{} + for k, v := range modes { + out[k.String()] = v + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// AddSegmentToCampaign enrols the segment's current members as campaign leads. +func (h *Handler) AddSegmentToCampaign(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + var in models.SegmentAddToCampaign + if err := c.ShouldBindJSON(&in); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + res, xerr := h.SegmentService.AddToCampaign(c.Request.Context(), orgID, middleware.GetUserID(c), id, &in) + if xerr != nil { + errx.Handle(c, xerr) + return + } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &res.CampaignID, nil, map[string]string{"segment_id": id.String(), "added": itoa(res.Added)}) + c.JSON(http.StatusOK, res) +} + +// ListSegmentOverrides lists the contacts pinned into or out of a segment. +func (h *Handler) ListSegmentOverrides(c *gin.Context) { + orgID, id, ok := segmentScope(c) + if !ok { + return + } + out, xerr := h.SegmentService.Overrides(c.Request.Context(), orgID, id) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// ListContactSegments reports every segment of the org with whether this +// contact is a member and any manual override on it. +func (h *Handler) ListContactSegments(c *gin.Context) { + contactID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + out, xerr := h.SegmentService.SegmentsForContact(c.Request.Context(), *orgID, contactID) + if xerr != nil { + errx.Handle(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index be842f0d..f126ca02 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -574,6 +574,7 @@ func Run( contacts.GET("/:id/emails", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactEmails) contacts.GET("/:id/timeline", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactTimeline) contacts.GET("/:id/campaigns", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactCampaignStates) + contacts.GET("/:id/segments", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactSegments) // AI contact research (dedicated AI_RESEARCH scope; JWT callers by // the matching contact permission). Batch queues and drains in the @@ -592,6 +593,24 @@ func Run( } // Group endpoints map to the resources they organize: campaign + // Segments: saved contact audiences. Contact permissions on both + // sides, since a segment is only a view over contacts. + segments := protected.Group("/segments") + segments.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + segments.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegments) + segments.GET("/fields", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegmentFields) + segments.POST("/preview", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.PreviewSegment) + segments.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.CreateSegment) + segments.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetSegment) + segments.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateSegment) + segments.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteSegment) + segments.POST("/:id/members", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.SetSegmentMembers) + segments.POST("/:id/members/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetSegmentMemberModes) + segments.GET("/:id/overrides", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListSegmentOverrides) + segments.POST("/:id/add-to-campaign", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.AddSegmentToCampaign) + } + // folders, email-account tags, and contact categories. grouph.New(protected, h.FolderService, h.AuditService, "folders", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns)) grouph.New(protected, h.TagService, h.AuditService, "tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails)) From daf946fc45b002bcd126a4b40fa1d9345b54a806 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 08/22] feat: add the add_to_segment and remove_from_segment sequence action steps, executed on both the scheduled campaign path and the instant reply path, wired into the backend and consumer --- cmd/consumer/main.go | 4 ++++ internal/app/advanced/events.go | 10 ++++++++++ internal/app/advanced/reply_actions.go | 11 +++++++++++ internal/app/advanced/service.go | 1 + internal/models/sequence.go | 6 +++++- internal/repository/pg_contact_campaign_state.go | 4 ++++ internal/repository/sequence_actions.go | 3 ++- internal/tasks/campaign_task.go | 12 ++++++++++++ internal/tasks/service.go | 12 ++++++++++++ 9 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index f4c05b14..12219aed 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -291,6 +291,10 @@ func main() { nil, // tasksClient: the consumer does not schedule Cloud Tasks warmupService, ) + // Instant reply actions can pin a contact into or out of a segment. + if aware, ok := advancedService.(advanced.SegmentAware); ok { + aware.WireSegments(repository.NewSegmentRepository(primaryDB)) + } advancedService.WireDispatcher(webhookService) // Reply/open/click instant action chains run in THIS process (inbox ingest + // tracking consumer), so a "run_automation" node on an instant branch must be diff --git a/internal/app/advanced/events.go b/internal/app/advanced/events.go index b9b00412..ffe27ac8 100644 --- a/internal/app/advanced/events.go +++ b/internal/app/advanced/events.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" ) // EventDispatcher fans a platform event out to customer webhooks and, via the @@ -22,6 +23,15 @@ type EventDispatcher interface { // way (rather than via the constructor) so the dispatcher — which itself may // depend on services constructed later — can be supplied once the graph is // fully wired. No-op if never called: emit() guards on a nil dispatcher. +// WireSegments attaches the segment repository the instant add_to_segment / +// remove_from_segment actions write through. +func (s *service) WireSegments(r repository.SegmentRepository) { s.segmentRepo = r } + +// SegmentAware is the optional capability the caller uses to attach segments. +type SegmentAware interface { + WireSegments(r repository.SegmentRepository) +} + func (s *service) WireDispatcher(d EventDispatcher) { s.dispatcher = d } diff --git a/internal/app/advanced/reply_actions.go b/internal/app/advanced/reply_actions.go index 9fe2fdc6..3d0b97f6 100644 --- a/internal/app/advanced/reply_actions.go +++ b/internal/app/advanced/reply_actions.go @@ -269,6 +269,17 @@ func (s *service) executeInstantActionNode(ctx context.Context, campaign *models }); xerr != nil { s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) } + case "add_to_segment", "remove_from_segment": + if cfg.SegmentID == nil || campaign.OrganizationID == nil || s.segmentRepo == nil { + return + } + mode := models.SegmentMemberInclude + if cfg.Type == "remove_from_segment" { + mode = models.SegmentMemberExclude + } + if _, xerr := s.segmentRepo.SetMembers(ctx, *campaign.OrganizationID, *cfg.SegmentID, []uuid.UUID{contact.ID}, mode); xerr != nil { + s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) + } case "label_email": // Label the conversation the contact just replied on. The most recent // thread for the contact in the campaign owner's unibox is that reply. diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index d1dbc965..d533d983 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -156,6 +156,7 @@ type service struct { emailRepo repository.EmailRepository taskRepo repository.TaskRepository contactRepo repository.ContactRepository + segmentRepo repository.SegmentRepository campaignProgressRepo repository.CampaignProgressRepository crmRepo repository.CRMRepository categoryRepo repository.GroupRepository diff --git a/internal/models/sequence.go b/internal/models/sequence.go index 4553d04c..470b8743 100644 --- a/internal/models/sequence.go +++ b/internal/models/sequence.go @@ -51,7 +51,7 @@ type Sequence struct { // ActionConfig is the persisted config for a non-email (action/wait) node. Type // is the switch the task executes on; the remaining fields are type-scoped. type ActionConfig struct { - Type string `json:"type"` // wait | add_tag | remove_tag | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | fire_event | switch | ai_step | end + Type string `json:"type"` // wait | add_tag | remove_tag | add_to_segment | remove_from_segment | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | fire_event | switch | ai_step | end // wait WaitMinutes *int `json:"wait_minutes,omitempty"` @@ -59,6 +59,10 @@ type ActionConfig struct { // add_tag / remove_tag — a contact category id (product "tags" == categories) CategoryID *uuid.UUID `json:"category_id,omitempty"` + // add_to_segment / remove_from_segment — pins the contact into or out of a + // segment as a manual override. + SegmentID *uuid.UUID `json:"segment_id,omitempty"` + // label_email — apply unibox conversation labels to the contact's most recent // thread. Labels are the same registry as contact tags (categories), but in // the inbox they're "labels", so the field is label_ids. Reply-branch only. diff --git a/internal/repository/pg_contact_campaign_state.go b/internal/repository/pg_contact_campaign_state.go index d236de5a..484cfb15 100644 --- a/internal/repository/pg_contact_campaign_state.go +++ b/internal/repository/pg_contact_campaign_state.go @@ -208,6 +208,10 @@ func stepLabel(name, kind string, action []byte, emailOrdinal int) string { return "Add tag" case "remove_tag": return "Remove tag" + case "add_to_segment": + return "Add to segment" + case "remove_from_segment": + return "Remove from segment" case "unsubscribe": return "Unsubscribe" case "notify": diff --git a/internal/repository/sequence_actions.go b/internal/repository/sequence_actions.go index b7aef8b1..c72dbfd6 100644 --- a/internal/repository/sequence_actions.go +++ b/internal/repository/sequence_actions.go @@ -17,7 +17,8 @@ func validateActionConfig(a *models.ActionConfig) *errx.Error { return nil } switch a.Type { - case "wait", "add_tag", "remove_tag", "label_email", "unsubscribe", + case "wait", "add_tag", "remove_tag", "add_to_segment", "remove_from_segment", + "label_email", "unsubscribe", "notify", "create_task", "create_deal", "move_deal_stage", "run_automation", "fire_event", "end": // Type must be known. Sub-config (wait minutes, tag, event name, HTTP diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index a96e4240..8e61f25a 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -927,6 +927,18 @@ func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.C return xerr } return nil + case "add_to_segment", "remove_from_segment": + if cfg.SegmentID == nil || campaign.OrganizationID == nil || s.segmentRepo == nil { + return nil + } + mode := models.SegmentMemberInclude + if cfg.Type == "remove_from_segment" { + mode = models.SegmentMemberExclude + } + if _, xerr := s.segmentRepo.SetMembers(ctx, *campaign.OrganizationID, *cfg.SegmentID, []uuid.UUID{contact.ID}, mode); xerr != nil { + return xerr + } + return nil case "label_email": // Apply unibox labels to the contact's most recent conversation. A no-op // when the contact has no thread yet (returns "" thread, nil error). diff --git a/internal/tasks/service.go b/internal/tasks/service.go index 76e34485..9fcd30af 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -129,6 +129,7 @@ type tasksService struct { emailRepo repository.EmailRepository campaignRepo repository.CampaignRepository contactRepo repository.ContactRepository + segmentRepo repository.SegmentRepository campaignLogRepo repository.CampaignLogRepository // orgRiskRepo bars a restricted organization from the paid warmup pool. // Optional/nil-safe. @@ -270,3 +271,14 @@ func (s *tasksService) WireOrgRisk(r repository.OrgRiskRepository) { type OrgRiskAware interface { WireOrgRisk(r repository.OrgRiskRepository) } + +// WireSegments attaches the segment repository the add_to_segment and +// remove_from_segment action nodes write through. +func (s *tasksService) WireSegments(r repository.SegmentRepository) { + s.segmentRepo = r +} + +// SegmentAware is the optional capability the caller uses to attach segments. +type SegmentAware interface { + WireSegments(r repository.SegmentRepository) +} From 51ce95b9bc3b793aef7082d6e52e4cd120190651 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 09/22] feat: add live Postgres tests for the segment compiler covering every field family, all/any matching, manual overrides, nested segments, the contact-side view, search by segment_ids and idempotent campaign enrolment --- internal/repository/segment_live_test.go | 243 +++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 internal/repository/segment_live_test.go diff --git a/internal/repository/segment_live_test.go b/internal/repository/segment_live_test.go new file mode 100644 index 00000000..15892800 --- /dev/null +++ b/internal/repository/segment_live_test.go @@ -0,0 +1,243 @@ +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" +) + +// Issue #266: segments compile to SQL over the real schema. These prove each +// field family, the manual overrides and nested segments against Postgres. +// The shared fixture already holds one contact (Pied Piper), so org-wide +// counts are one higher than the three contacts seeded here. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveSegment -v + +type segmentFixture struct { + *sharedOrgFixture + category uuid.UUID + alice uuid.UUID // Acme, VP, in campaign, opened, categorised + bob uuid.UUID // Globex, custom title Engineer, unsubscribed + carol uuid.UUID // Acme, no activity +} + +func newSegmentFixture(t *testing.T) (*segmentFixture, SegmentRepository) { + t.Helper() + handle, pool := liveContactDB(t) + base := newSharedOrgFixture(t, pool) + f := &segmentFixture{sharedOrgFixture: base, category: uuid.New(), alice: uuid.New(), bob: uuid.New(), carol: uuid.New()} + ctx := context.Background() + exec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err) + } + } + tag := uuid.New().String()[:6] + contact := func(id uuid.UUID, first, company string, custom string, subscribed bool) { + exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, subscribed) + VALUES ($1, $2, $3, $4, $5, 'Seg', $6, '', $7::jsonb, $8)`, + id, f.owner, f.org, first+"-"+tag+"@"+company+".test", first, company, custom, subscribed) + } + contact(f.alice, "alice", "acme", `{"title":"VP Sales"}`, true) + contact(f.bob, "bob", "globex", `{"title":"Engineer"}`, false) + contact(f.carol, "carol", "acme", `{}`, true) + + exec(`INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Hot', '#ff0000', 0)`, f.category, f.owner) + exec(`INSERT INTO contact_categories (contact_id, category_id) VALUES ($1, $2)`, f.alice, f.category) + exec(`INSERT INTO campaign_leads (campaign_id, contact_id) VALUES ($1, $2)`, f.campaign, f.alice) + seq := uuid.New() + exec(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html) VALUES ($1, $2, $3, 'Email 1', 'Hi', 'Body', 'Body')`, seq, f.campaign, f.org) + exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, opened_at, opened_machine) + VALUES ($1, $2, $3, NOW() - interval '2 days', NOW() - interval '1 day', false)`, f.campaign, f.alice, seq) + + t.Cleanup(func() { + c := context.Background() + for _, step := range []struct { + sql string + arg any + }{ + {`DELETE FROM segments WHERE organization_id = $1`, f.org}, + {`DELETE FROM campaign_contact_progress WHERE campaign_id = $1`, f.campaign}, + {`DELETE FROM sequences WHERE campaign_id = $1`, f.campaign}, + {`DELETE FROM contact_categories WHERE category_id = $1`, f.category}, + {`DELETE FROM categories WHERE id = $1`, f.category}, + } { + if _, err := pool.Exec(c, step.sql, step.arg); err != nil { + t.Errorf("cleanup %q: %v", step.sql, err) + } + } + }) + return f, NewSegmentRepository(handle) +} + +func segCount(t *testing.T, repo SegmentRepository, org uuid.UUID, match models.SegmentMatch, conds ...models.SegmentCondition) int { + t.Helper() + if err := models.ValidateSegmentConditions(conds, nil); err != nil { + t.Fatalf("validate: %v", err) + } + n, xerr := repo.Count(context.Background(), org, nil, match, conds) + if xerr != nil { + t.Fatalf("count: %v", xerr) + } + return n +} + +func TestLiveSegmentConditionsMatchEachFamily(t *testing.T) { + f, repo := newSegmentFixture(t) + cases := []struct { + name string + want int + cond models.SegmentCondition + }{ + {"company equals", 2, models.SegmentCondition{Field: "company", Operator: "equals", Value: "ACME"}}, + {"company contains escapes wildcards", 0, models.SegmentCondition{Field: "company", Operator: "contains", Value: "%"}}, + {"email domain", 1, models.SegmentCondition{Field: "email_domain", Operator: "equals", Value: "globex.test"}}, + {"custom field", 1, models.SegmentCondition{Field: "custom.title", Operator: "starts_with", Value: "vp"}}, + {"custom field empty", 2, models.SegmentCondition{Field: "custom.title", Operator: "is_empty"}}, + {"subscribed false", 1, models.SegmentCondition{Field: "subscribed", Operator: "is_false"}}, + {"category in", 1, models.SegmentCondition{Field: "category", Operator: "in", Values: []string{f.category.String()}}}, + {"category none", 3, models.SegmentCondition{Field: "category", Operator: "is_empty"}}, + {"campaign in", 1, models.SegmentCondition{Field: "campaign", Operator: "in", Values: []string{f.campaign.String()}}}, + {"campaign not in", 3, models.SegmentCondition{Field: "campaign", Operator: "not_in", Values: []string{f.campaign.String()}}}, + {"campaign count", 3, models.SegmentCondition{Field: "campaign_count", Operator: "equals", Value: "0"}}, + {"emails opened", 1, models.SegmentCondition{Field: "emails_opened", Operator: "gte", Value: "1"}}, + {"last opened within", 1, models.SegmentCondition{Field: "last_opened_at", Operator: "within_days", Value: "7"}}, + {"last opened not within", 3, models.SegmentCondition{Field: "last_opened_at", Operator: "not_within_days", Value: "7"}}, + {"last replied empty", 4, models.SegmentCondition{Field: "last_replied_at", Operator: "is_empty"}}, + {"created after yesterday", 4, models.SegmentCondition{Field: "created_at", Operator: "after", Value: "2000-01-01"}}, + {"source enum", 4, models.SegmentCondition{Field: "source", Operator: "in", Values: []string{"unknown"}}}, + {"suppressed", 0, models.SegmentCondition{Field: "suppressed", Operator: "is_true"}}, + } + for _, tc := range cases { + if got := segCount(t, repo, f.org, models.SegmentMatchAll, tc.cond); got != tc.want { + t.Errorf("%s: got %d, want %d", tc.name, got, tc.want) + } + } +} + +func TestLiveSegmentMatchAnyAndAll(t *testing.T) { + f, repo := newSegmentFixture(t) + acme := models.SegmentCondition{Field: "company", Operator: "equals", Value: "acme"} + opened := models.SegmentCondition{Field: "emails_opened", Operator: "gte", Value: "1"} + if got := segCount(t, repo, f.org, models.SegmentMatchAll, acme, opened); got != 1 { + t.Errorf("all: got %d, want 1", got) + } + unsub := models.SegmentCondition{Field: "subscribed", Operator: "is_false"} + if got := segCount(t, repo, f.org, models.SegmentMatchAny, opened, unsub); got != 2 { + t.Errorf("any: got %d, want 2", got) + } +} + +func TestLiveSegmentOverridesNestingAndCampaign(t *testing.T) { + f, repo := newSegmentFixture(t) + ctx := context.Background() + acme, xerr := repo.Create(ctx, f.org, &f.owner, &models.Segment{ + Name: "Acme", Color: "#0284c7", Match: models.SegmentMatchAll, + Conditions: []models.SegmentCondition{{Field: "company", Operator: "equals", Value: "acme"}}, + }) + if xerr != nil { + t.Fatalf("create: %v", xerr) + } + if acme.ContactCount != 2 { + t.Fatalf("acme count = %d, want 2", acme.ContactCount) + } + + // Exclude carol, include bob: membership is (conditions OR include) AND NOT exclude. + if _, xerr := repo.SetMembers(ctx, f.org, acme.ID, []uuid.UUID{f.carol}, models.SegmentMemberExclude); xerr != nil { + t.Fatalf("exclude: %v", xerr) + } + if _, xerr := repo.SetMembers(ctx, f.org, acme.ID, []uuid.UUID{f.bob}, models.SegmentMemberInclude); xerr != nil { + t.Fatalf("include: %v", xerr) + } + got, xerr := repo.Get(ctx, f.org, acme.ID) + if xerr != nil { + t.Fatalf("get: %v", xerr) + } + if got.ContactCount != 2 || got.IncludedCount != 1 || got.ExcludedCount != 1 { + t.Fatalf("after overrides: count=%d included=%d excluded=%d", got.ContactCount, got.IncludedCount, got.ExcludedCount) + } + modes, xerr := repo.MemberModes(ctx, acme.ID, []uuid.UUID{f.alice, f.bob, f.carol}) + if xerr != nil { + t.Fatalf("modes: %v", xerr) + } + if modes[f.bob] != models.SegmentMemberInclude || modes[f.carol] != models.SegmentMemberExclude || modes[f.alice] != "" { + t.Fatalf("modes = %v", modes) + } + + // The contact-side view reports membership and the override per segment, + // and the overrides listing shows both pinned contacts. + forBob, xerr := repo.SegmentsForContact(ctx, f.org, f.bob) + if xerr != nil { + t.Fatalf("segments for contact: %v", xerr) + } + if len(forBob) != 1 || !forBob[0].Member || forBob[0].Mode != models.SegmentMemberInclude { + t.Fatalf("segments for bob = %+v", forBob) + } + forCarol, _ := repo.SegmentsForContact(ctx, f.org, f.carol) + if len(forCarol) != 1 || forCarol[0].Member || forCarol[0].Mode != models.SegmentMemberExclude { + t.Fatalf("segments for carol = %+v", forCarol) + } + overrides, xerr := repo.ListOverrides(ctx, f.org, acme.ID) + if xerr != nil || len(overrides) != 2 { + t.Fatalf("overrides = %+v, %v", overrides, xerr) + } + + // A segment built on another one sees its overrides. + nested, xerr := repo.Create(ctx, f.org, &f.owner, &models.Segment{ + Name: "Acme not opened", Color: "#0284c7", Match: models.SegmentMatchAll, + Conditions: []models.SegmentCondition{ + {Field: "segment", Operator: "in", Values: []string{acme.ID.String()}}, + {Field: "emails_opened", Operator: "equals", Value: "0"}, + }, + }) + if xerr != nil { + t.Fatalf("create nested: %v", xerr) + } + if nested.ContactCount != 1 { // bob (included), since carol is excluded and alice opened + t.Fatalf("nested count = %d, want 1", nested.ContactCount) + } + names, xerr := repo.ReferencedBy(ctx, f.org, acme.ID) + if xerr != nil || len(names) != 1 || names[0] != "Acme not opened" { + t.Fatalf("referenced by = %v, %v", names, xerr) + } + + // The contacts search honours segment_ids. + handle, _ := liveContactDB(t) + contacts := NewContactRepostory(handle) + res, xerr := contacts.Search(ctx, f.org.String(), nil, nil, models.SearchContacts{SegmentIDs: []string{acme.ID.String()}}, 50) + if xerr != nil { + t.Fatalf("search: %v", xerr) + } + if len(res.Data) != 2 { + t.Fatalf("search by segment = %d contacts, want 2", len(res.Data)) + } + + // Enrolling the segment adds only the members not already leads. + out, xerr := repo.AddToCampaign(ctx, f.org, f.mate.String(), acme.ID, f.other) + if xerr != nil { + t.Fatalf("add to campaign: %v", xerr) + } + if out.Added != 2 || out.Members != 2 { + t.Fatalf("add to campaign = %+v", out) + } + out, xerr = repo.AddToCampaign(ctx, f.org, f.mate.String(), acme.ID, f.other) + if xerr != nil || out.Added != 0 { + t.Fatalf("second add = %+v, %v", out, xerr) + } + + // Duplicate names collide, and a referenced segment cannot be deleted + // (the service refuses; the repository reports the reference). + if _, xerr := repo.Create(ctx, f.org, nil, &models.Segment{Name: "ACME", Color: "#0284c7", Match: models.SegmentMatchAll}); xerr == nil { + t.Fatalf("duplicate name accepted") + } + if xerr := repo.Delete(ctx, f.org, nested.ID); xerr != nil { + t.Fatalf("delete nested: %v", xerr) + } + if got, _ := repo.Get(ctx, f.org, acme.ID); got == nil || got.ContactCount != 2 { + t.Fatalf("acme after nested delete: %+v", got) + } +} From 960f42b8463763d06364c80d9aa0865e6f7e71b4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 10/22] feat: add the dashboard segment model, API client and react-query hooks for segments, fields, preview, overrides, member modes, contact-side membership and campaign enrolment --- web/src/lib/api/client/app/segments/index.ts | 81 ++++++++++ web/src/lib/api/hooks/app/segments/index.ts | 120 +++++++++++++++ .../lib/api/models/app/segments/Segment.ts | 141 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 web/src/lib/api/client/app/segments/index.ts create mode 100644 web/src/lib/api/hooks/app/segments/index.ts create mode 100644 web/src/lib/api/models/app/segments/Segment.ts diff --git a/web/src/lib/api/client/app/segments/index.ts b/web/src/lib/api/client/app/segments/index.ts new file mode 100644 index 00000000..4b28db35 --- /dev/null +++ b/web/src/lib/api/client/app/segments/index.ts @@ -0,0 +1,81 @@ +import Request from "../../Request"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import type { + ContactSegment, + SegmentOverride, + SegmentAddToCampaignResult, + SegmentFieldSpec, + SegmentMemberMode, + SegmentPreview, + SegmentWrite, +} from "@/lib/api/models/app/segments/Segment"; + +export async function listSegments(): Promise { + const res = await Request<{ data: Segment[] }>({ method: "GET", url: "/segments", authorization: true }); + return res.data ?? []; +} + +export async function getSegment(id: string): Promise { + return await Request({ method: "GET", url: `/segments/${id}`, authorization: true }); +} + +export async function listSegmentFields(): Promise { + const res = await Request<{ data: SegmentFieldSpec[] }>({ method: "GET", url: "/segments/fields", authorization: true }); + return res.data ?? []; +} + +export async function createSegment(data: SegmentWrite): Promise { + return await Request({ method: "POST", url: "/segments", data, authorization: true }); +} + +export async function updateSegment(id: string, data: SegmentWrite): Promise { + return await Request({ method: "PATCH", url: `/segments/${id}`, data, authorization: true }); +} + +export async function deleteSegment(id: string): Promise { + await Request({ method: "DELETE", url: `/segments/${id}`, authorization: true }); +} + +export async function previewSegment(data: SegmentPreview): Promise { + const res = await Request<{ contact_count: number }>({ method: "POST", url: "/segments/preview", data, authorization: true }); + return res.contact_count; +} + +export async function setSegmentMembers(id: string, contacts: string[], mode: SegmentMemberMode): Promise { + const res = await Request<{ updated: number }>({ + method: "POST", + url: `/segments/${id}/members`, + data: { contacts, mode }, + authorization: true, + }); + return res.updated; +} + +export async function lookupSegmentMembers(id: string, contacts: string[]): Promise> { + const res = await Request<{ data: Record }>({ + method: "POST", + url: `/segments/${id}/members/lookup`, + data: { contacts }, + authorization: true, + }); + return res.data ?? {}; +} + +export async function listContactSegments(contactId: string): Promise { + const res = await Request<{ data: ContactSegment[] }>({ method: "GET", url: `/contacts/${contactId}/segments`, authorization: true }); + return res.data ?? []; +} + +export async function listSegmentOverrides(id: string): Promise { + const res = await Request<{ data: SegmentOverride[] }>({ method: "GET", url: `/segments/${id}/overrides`, authorization: true }); + return res.data ?? []; +} + +export async function addSegmentToCampaign(id: string, campaignId: string): Promise { + return await Request({ + method: "POST", + url: `/segments/${id}/add-to-campaign`, + data: { campaign_id: campaignId }, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/segments/index.ts b/web/src/lib/api/hooks/app/segments/index.ts new file mode 100644 index 00000000..4805026e --- /dev/null +++ b/web/src/lib/api/hooks/app/segments/index.ts @@ -0,0 +1,120 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addSegmentToCampaign, + listContactSegments, + listSegmentOverrides, + createSegment, + deleteSegment, + getSegment, + listSegmentFields, + listSegments, + lookupSegmentMembers, + previewSegment, + setSegmentMembers, + updateSegment, +} from "@/lib/api/client/app/segments"; +import type { SegmentMemberMode, SegmentPreview, SegmentWrite } from "@/lib/api/models/app/segments/Segment"; + +// Every segment read lives under ["segments"]: the realtime spine invalidates +// that prefix on any segment or contact mutation, since membership is live. +export function useSegments(enabled = true) { + return useQuery({ queryKey: ["segments", "list"], queryFn: listSegments, enabled }); +} + +export function useSegment(id: string | undefined) { + return useQuery({ queryKey: ["segments", id], queryFn: () => getSegment(id as string), enabled: !!id }); +} + +export function useSegmentFields(enabled = true) { + return useQuery({ queryKey: ["segments", "fields"], queryFn: listSegmentFields, enabled, staleTime: 5 * 60 * 1000 }); +} + +export function useSegmentPreview(preview: SegmentPreview | null) { + return useQuery({ + queryKey: ["segments", "preview", preview], + queryFn: () => previewSegment(preview as SegmentPreview), + enabled: preview !== null, + retry: 0, + }); +} + +export function useSegmentMemberModes(id: string | undefined, contacts: string[]) { + return useQuery({ + queryKey: ["segments", id, "members", contacts], + queryFn: () => lookupSegmentMembers(id as string, contacts), + enabled: !!id && contacts.length > 0, + }); +} + +// Keyed under ["contacts", id] so a contact mutation refreshes it, and under +// ["segments"] via the spine so a segment edit does too. +export function useContactSegments(contactId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ["contacts", contactId, "segments"], + queryFn: () => listContactSegments(contactId as string), + enabled: enabled && !!contactId, + staleTime: 30_000, + }); +} + +export function useSegmentOverrides(id: string | undefined, enabled = true) { + return useQuery({ + queryKey: ["segments", id, "overrides"], + queryFn: () => listSegmentOverrides(id as string), + enabled: enabled && !!id, + }); +} + +function invalidateSegments(queryClient: ReturnType) { + return queryClient.invalidateQueries({ queryKey: ["segments"] }); +} + +export function useCreateSegment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: SegmentWrite) => createSegment(data), + onSuccess: () => invalidateSegments(queryClient), + }); +} + +export function useUpdateSegment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: SegmentWrite }) => updateSegment(id, data), + // A changed definition moves rows in and out of the segment's contact list. + onSuccess: () => + Promise.all([invalidateSegments(queryClient), queryClient.invalidateQueries({ queryKey: ["contacts", "list"] })]), + }); +} + +export function useDeleteSegment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteSegment(id), + onSuccess: () => invalidateSegments(queryClient), + }); +} + +export function useSetSegmentMembers() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, contacts, mode }: { id: string; contacts: string[]; mode: SegmentMemberMode }) => + setSegmentMembers(id, contacts, mode), + // ["contacts"] as a whole: the list moves and each pinned contact's + // own segments panel changes. + onSuccess: () => + Promise.all([invalidateSegments(queryClient), queryClient.invalidateQueries({ queryKey: ["contacts"] })]), + }); +} + +export function useAddSegmentToCampaign() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, campaignId }: { id: string; campaignId: string }) => addSegmentToCampaign(id, campaignId), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ["contacts"] }), + queryClient.invalidateQueries({ queryKey: ["campaigns"] }), + ]), + }); +} diff --git a/web/src/lib/api/models/app/segments/Segment.ts b/web/src/lib/api/models/app/segments/Segment.ts new file mode 100644 index 00000000..91c47b69 --- /dev/null +++ b/web/src/lib/api/models/app/segments/Segment.ts @@ -0,0 +1,141 @@ +export type SegmentMatch = "all" | "any"; + +export type SegmentMemberMode = "include" | "exclude" | "auto"; + +export type SegmentFieldKind = + | "text" + | "enum" + | "bool" + | "date" + | "number" + | "category" + | "campaign" + | "segment"; + +export interface SegmentCondition { + field: string; + operator: string; + value?: string; + values?: string[]; +} + +export interface SegmentFieldSpec { + field: string; + label: string; + group: string; + kind: SegmentFieldKind; + options?: string[]; +} + +export default interface Segment { + id: string; + organization_id: string; + created_by?: string; + name: string; + description: string; + color: string; + match: SegmentMatch; + conditions: SegmentCondition[]; + contact_count: number; + included_count: number; + excluded_count: number; + created_at: Date; + updated_at: Date; +} + +export interface SegmentWrite { + name?: string; + description?: string; + color?: string; + match?: SegmentMatch; + conditions?: SegmentCondition[]; +} + +export interface SegmentPreview { + id?: string; + match: SegmentMatch; + conditions: SegmentCondition[]; +} + +// One segment as seen from a contact: member or not, plus any manual override. +export interface ContactSegment { + id: string; + name: string; + color: string; + mode?: "include" | "exclude"; + member: boolean; +} + +// A contact pinned into or out of a segment. +export interface SegmentOverride { + contact_id: string; + first_name: string; + last_name: string; + email: string; + company: string; + mode: "include" | "exclude"; + created_at: Date; +} + +export interface SegmentAddToCampaignResult { + campaign_id: string; + added: number; + members: number; +} + +// Operators per field kind, mirrored from the backend catalog. +export const SEGMENT_OPERATORS: Record = { + text: [ + { id: "equals", label: "is" }, + { id: "not_equals", label: "is not" }, + { id: "contains", label: "contains" }, + { id: "not_contains", label: "does not contain" }, + { id: "starts_with", label: "starts with" }, + { id: "ends_with", label: "ends with" }, + { id: "is_empty", label: "is empty" }, + { id: "is_not_empty", label: "is not empty" }, + ], + enum: [ + { id: "in", label: "is any of" }, + { id: "not_in", label: "is none of" }, + ], + bool: [ + { id: "is_true", label: "is yes" }, + { id: "is_false", label: "is no" }, + ], + date: [ + { id: "within_days", label: "in the last" }, + { id: "not_within_days", label: "not in the last" }, + { id: "after", label: "is after" }, + { id: "before", label: "is before" }, + { id: "is_empty", label: "never" }, + { id: "is_not_empty", label: "ever" }, + ], + number: [ + { id: "equals", label: "is" }, + { id: "not_equals", label: "is not" }, + { id: "gt", label: "is more than" }, + { id: "gte", label: "is at least" }, + { id: "lt", label: "is less than" }, + { id: "lte", label: "is at most" }, + ], + category: [ + { id: "in", label: "has any of" }, + { id: "not_in", label: "has none of" }, + { id: "is_empty", label: "has none" }, + { id: "is_not_empty", label: "has any" }, + ], + campaign: [ + { id: "in", label: "is in any of" }, + { id: "not_in", label: "is in none of" }, + { id: "is_empty", label: "is in no campaign" }, + { id: "is_not_empty", label: "is in a campaign" }, + ], + segment: [ + { id: "in", label: "is in any of" }, + { id: "not_in", label: "is in none of" }, + ], +}; + +// Operators that take no value at all. +export const VALUELESS_OPERATORS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]); From d831aba034662b0c98c08c2c33e322e5329b4552 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 11/22] feat: add segment_ids to the dashboard contact search model and route segment audit events and contact changes to the segments queries on the realtime spine --- web/src/hooks/useRealtimeEvents.ts | 6 +++++- web/src/lib/api/models/app/contacts/SearchContacts.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index c242e697..dd20ccf9 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -332,7 +332,10 @@ export function useRealtimeEvents() { markSelfMutation(entityType, entityId) } const spine: Record = { - contact: [['contacts']], + // Segment membership is computed from contact data, so a contact + // change moves segment counts too. + contact: [['contacts'], ['segments']], + segment: [['segments'], ['contacts', 'list']], campaign: [['campaigns'], ['analytics']], step: [['campaigns']], // ['emails'] rather than ['emails', 'list']: it prefix-matches the @@ -398,6 +401,7 @@ export function useRealtimeEvents() { const keys = spine[entityType] if (keys) invalidate(keys) if (entityId && entityType === 'contact') invalidate([['contacts', entityId]]) + if (entityId && entityType === 'segment') invalidate([['segments', entityId]]) if (entityId && entityType === 'campaign') invalidate([['campaigns', entityId]]) if (entityId && entityType === 'automation') invalidate([['automations', entityId]]) return diff --git a/web/src/lib/api/models/app/contacts/SearchContacts.ts b/web/src/lib/api/models/app/contacts/SearchContacts.ts index 922f7266..4af4a886 100644 --- a/web/src/lib/api/models/app/contacts/SearchContacts.ts +++ b/web/src/lib/api/models/app/contacts/SearchContacts.ts @@ -11,6 +11,8 @@ export default interface SearchContacts { lead_status?: LeadStatus; engagement?: LeadEngagement; category_ids?: string[]; + // Contact must be a member of ALL of these segments. + segment_ids?: string[]; min_campaigns?: number; max_campaigns?: number; subscribed?: boolean; From 3bfc36c71a1a11ca77fc393d66a1d63fc481a63f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 12/22] feat: add the segment condition builder drawer with a live match count and the campaign, segment and enum multi-pickers it uses --- .../components/app/segments/SegmentEditor.tsx | 492 ++++++++++++++++++ .../app/segments/SegmentPickers.tsx | 218 ++++++++ 2 files changed, 710 insertions(+) create mode 100644 web/src/components/app/segments/SegmentEditor.tsx create mode 100644 web/src/components/app/segments/SegmentPickers.tsx diff --git a/web/src/components/app/segments/SegmentEditor.tsx b/web/src/components/app/segments/SegmentEditor.tsx new file mode 100644 index 00000000..d07aa68d --- /dev/null +++ b/web/src/components/app/segments/SegmentEditor.tsx @@ -0,0 +1,492 @@ +// SegmentEditor — right-side drawer that creates or edits a segment: name, +// color, all/any match and the condition list, with a live "matches N +// contacts" preview fed by POST /segments/preview. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Loader2Icon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { Label, NumberInput, TextInput } from "@/components/ui/field"; +import { SelectMenu, type SelectOption } from "@/components/ui/select-menu"; +import { DatePicker } from "@/components/ui/DatePicker"; +import { Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; +import CategoryPicker from "@/components/app/contacts/CategoryPicker"; +import { CampaignMultiPicker, EnumMultiPicker, SegmentMultiPicker } from "./SegmentPickers"; +import { useConfirm } from "@/hooks/context/confirm"; +import { + useCreateSegment, + useSegmentFields, + useSegmentPreview, + useUpdateSegment, +} from "@/lib/api/hooks/app/segments"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import { + SEGMENT_OPERATORS, + VALUELESS_OPERATORS, + type SegmentCondition, + type SegmentFieldSpec, + type SegmentMatch, +} from "@/lib/api/models/app/segments/Segment"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import { cn } from "@/lib/utils"; + +const COLORS = ["#0284c7", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0d9488", "#475569"]; + +interface Draft { + name: string; + description: string; + color: string; + match: SegmentMatch; + conditions: SegmentCondition[]; +} + +function draftFrom(segment?: Segment | null): Draft { + return { + name: segment?.name ?? "", + description: segment?.description ?? "", + color: segment?.color ?? COLORS[0], + match: segment?.match ?? "all", + conditions: segment?.conditions?.map((c) => ({ ...c, values: c.values ? [...c.values] : undefined })) ?? [], + }; +} + +function sameDraft(a: Draft, b: Draft): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +// A condition the server would accept: a field, an operator, and a value +// whenever the operator wants one. +function complete(c: SegmentCondition): boolean { + if (!c.field || !c.operator) return false; + if (VALUELESS_OPERATORS.has(c.operator)) return true; + if (c.operator === "in" || c.operator === "not_in") return (c.values?.length ?? 0) > 0; + return (c.value ?? "").trim() !== ""; +} + +export default function SegmentEditor({ + open, + onClose, + segment, + onSaved, +}: { + open: boolean; + onClose: () => void; + // Edit this segment; omit to create a new one. + segment?: Segment | null; + onSaved?: (segment: Segment) => void; +}) { + const confirm = useConfirm(); + const fields = useSegmentFields(open); + const create = useCreateSegment(); + const update = useUpdateSegment(); + + const [draft, setDraft] = React.useState(() => draftFrom(segment)); + const [initial, setInitial] = React.useState(() => draftFrom(segment)); + React.useEffect(() => { + if (open) { + const d = draftFrom(segment); + setDraft(d); + setInitial(d); + } + }, [open, segment]); + const dirty = !sameDraft(draft, initial); + + // Live count, debounced so typing a value does not fire a query per key. + const [debounced, setDebounced] = React.useState(null); + React.useEffect(() => { + if (!open) return; + const t = setTimeout(() => setDebounced(draft), 350); + return () => clearTimeout(t); + }, [draft, open]); + const previewInput = React.useMemo(() => { + if (!debounced) return null; + const conds = debounced.conditions.filter(complete); + return { id: segment?.id, match: debounced.match, conditions: conds }; + }, [debounced, segment?.id]); + const preview = useSegmentPreview(open ? previewInput : null); + + const busy = create.isPending || update.isPending; + const requestClose = React.useCallback(() => { + if (busy) return; + if (dirty) { + confirm.show("Discard your changes to this segment?", async () => onClose()); + return; + } + onClose(); + }, [busy, dirty, confirm, onClose]); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (document.querySelector("[data-floating], [role='alertdialog']")) return; + requestClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, requestClose]); + + const incomplete = draft.conditions.filter((c) => !complete(c)).length; + const canSave = draft.name.trim() !== "" && incomplete === 0 && !busy; + const blocker = + draft.name.trim() === "" + ? "Give the segment a name." + : incomplete > 0 + ? `${incomplete} condition${incomplete === 1 ? " is" : "s are"} missing a value.` + : null; + + async function save() { + if (!canSave) return; + const body = { + name: draft.name.trim(), + description: draft.description.trim(), + color: draft.color, + match: draft.match, + conditions: draft.conditions, + }; + try { + const saved = segment + ? await update.mutateAsync({ id: segment.id, data: body }) + : await create.mutateAsync(body); + toast.success(segment ? "Segment updated" : "Segment created"); + setInitial(draft); + onSaved?.(saved); + onClose(); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + function setCondition(i: number, next: SegmentCondition) { + setDraft((d) => ({ ...d, conditions: d.conditions.map((c, j) => (j === i ? next : c)) })); + } + + const specs = fields.data ?? []; + + return ( + + {open && ( + + e.stopPropagation()} + className="flex flex-col bg-white w-[560px] max-w-[95%] h-full border-l border-slate-200 shadow-[-8px_0_24px_-12px_rgba(15,23,42,0.12)]" + > +
+ + {segment ? "Edit segment" : "New segment"} + +
+ + {preview.isFetching ? ( + + ) : preview.isError ? ( + Cannot count + ) : ( + <> + Matches{" "} + + {(preview.data ?? 0).toLocaleString()} + {" "} + contact{preview.data === 1 ? "" : "s"} + + )} + + +
+ +
+
+
+
+ + setDraft((d) => ({ ...d, name: v }))} + placeholder="Warm leads in fintech" + autoFocus={!segment} + className="w-full" + /> +
+
+ +
+ {COLORS.map((c) => ( +
+
+
+
+ + setDraft((d) => ({ ...d, description: v }))} + placeholder="What this audience is for (optional)" + className="w-full" + /> +
+
+ +
+
+ Conditions + {draft.conditions.length} +
+ Match + + value={draft.match} + onChange={(v) => setDraft((d) => ({ ...d, match: v }))} + options={[ + { value: "all", label: "all" }, + { value: "any", label: "any" }, + ]} + /> +
+
+ + {draft.conditions.length === 0 && ( +
+

No conditions yet

+

+ Without conditions the segment only holds contacts you add by hand. +

+
+ )} + +
+ {draft.conditions.map((c, i) => ( + setCondition(i, next)} + onRemove={() => + setDraft((d) => ({ ...d, conditions: d.conditions.filter((_, j) => j !== i) })) + } + /> + ))} +
+ + +
+
+ +
+ + {blocker ?? (segment ? "Changes apply to every list using this segment." : "Membership stays live as contacts change.")} + + + +
+ + + )} + + ); +} + +function ConditionRow({ + index, + condition, + specs, + match, + selfId, + onChange, + onRemove, +}: { + index: number; + condition: SegmentCondition; + specs: SegmentFieldSpec[]; + match: SegmentMatch; + selfId?: string; + onChange: (next: SegmentCondition) => void; + onRemove: () => void; +}) { + const spec = specs.find((s) => s.field === condition.field); + const fieldOptions = React.useMemo( + () => specs.map((s) => ({ value: s.field, label: s.label, group: s.group })), + [specs], + ); + const operators = spec ? SEGMENT_OPERATORS[spec.kind] : []; + const operatorOptions: SelectOption[] = operators.map((o) => ({ value: o.id, label: o.label })); + + function pickField(field: string) { + const next = specs.find((s) => s.field === field); + const ops = next ? SEGMENT_OPERATORS[next.kind] : []; + onChange({ field, operator: ops[0]?.id ?? "", value: "", values: undefined }); + } + + function pickOperator(operator: string) { + onChange({ ...condition, operator, value: VALUELESS_OPERATORS.has(operator) ? "" : condition.value, values: condition.values }); + } + + return ( +
+
+ + {index === 0 ? "If" : match === "all" ? "and" : "or"} + + + + +
+ {spec && !VALUELESS_OPERATORS.has(condition.operator) && ( +
+ +
+ )} +
+ ); +} + +function ValueInput({ + spec, + condition, + selfId, + onChange, +}: { + spec: SegmentFieldSpec; + condition: SegmentCondition; + selfId?: string; + onChange: (next: SegmentCondition) => void; +}) { + const values = condition.values ?? []; + const setValues = (next: string[]) => onChange({ ...condition, values: next }); + const setValue = (next: string) => onChange({ ...condition, value: next }); + switch (spec.kind) { + case "text": + return ; + case "number": + return ( + setValue(String(Math.max(0, Math.round(n))))} + min={0} + className="w-40" + /> + ); + case "date": + if (condition.operator === "within_days" || condition.operator === "not_within_days") { + return ( + setValue(String(Math.min(3650, Math.max(1, Math.round(n)))))} + min={1} + max={3650} + suffix="days" + className="w-40" + /> + ); + } + return ( + + ); + case "enum": + return ; + case "category": + return ; + case "campaign": + return ; + case "segment": + return ; + default: + return null; + } +} diff --git a/web/src/components/app/segments/SegmentPickers.tsx b/web/src/components/app/segments/SegmentPickers.tsx new file mode 100644 index 00000000..b4cceebb --- /dev/null +++ b/web/src/components/app/segments/SegmentPickers.tsx @@ -0,0 +1,218 @@ +// Multi-select pickers used by the segment condition builder: campaigns, +// segments and enum options. Same chip box + dropdown language as +// CategoryPicker, without inline creation. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, PlusIcon, XIcon } from "lucide-react"; + +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import { useSegments } from "@/lib/api/hooks/app/segments"; +import { cn } from "@/lib/utils"; + +export interface PickOption { + id: string; + label: string; + color?: string; +} + +export function MultiPicker({ + value, + onChange, + options, + placeholder = "Pick…", + searchable = true, + className, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: PickOption[]; + placeholder?: string; + searchable?: boolean; + className?: string; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + const placement = useFlipPlacement(triggerRef, open, 270); + + const byId = React.useMemo(() => new Map(options.map((o) => [o.id, o])), [options]); + const chips = value.map((id) => byId.get(id) ?? { id, label: "Unknown" }); + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.label.toLowerCase().includes(q)); + }, [options, query]); + + function toggle(id: string) { + onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]); + } + + return ( +
+
+ {chips.length === 0 ? ( + + ) : ( +
+ {chips.map((c) => ( + + {c.color && } + {c.label} + + + ))} + +
+ )} +
+ + {open && ( + + {searchable && ( +
+ setQuery(e.target.value)} + placeholder="Search…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ )} +
+ {filtered.length === 0 && ( +
Nothing to pick.
+ )} + {filtered.map((o) => { + const checked = value.includes(o.id); + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +export function CampaignMultiPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) { + const campaigns = useCampaigns({ query: "", folder: "", limit: 100 }); + const options = React.useMemo( + () => campaigns.campaigns.map((c) => ({ id: c.id, label: c.name })), + [campaigns.campaigns], + ); + // Walk every page once so the picker holds the whole workspace. + const { hasNextPage, isFetchingNextPage, fetchNextPage } = campaigns; + React.useEffect(() => { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + return ; +} + +export function SegmentMultiPicker({ + value, + onChange, + exclude, +}: { + value: string[]; + onChange: (next: string[]) => void; + exclude?: string; +}) { + const segments = useSegments(); + const options = React.useMemo( + () => + (segments.data ?? []) + .filter((s) => s.id !== exclude) + .map((s) => ({ id: s.id, label: s.name, color: s.color })), + [segments.data, exclude], + ); + return ; +} + +const ENUM_LABELS: Record = { + unknown: "Unknown", + manual: "Added manually", + campaign: "Added from a campaign", + import: "Imported", + sheet_sync: "Google Sheets sync", + api: "API", + ai_assistant: "AI assistant", + valid: "Valid", + risky: "Risky", + invalid: "Invalid", + gmail: "Gmail", + outlook: "Outlook", + other: "Other", +}; + +export function EnumMultiPicker({ + value, + onChange, + options, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: string[]; +}) { + const opts = React.useMemo(() => options.map((o) => ({ id: o, label: ENUM_LABELS[o] ?? o })), [options]); + return ; +} From c8b901b2044a04acd6f6e848f1f11d93d55d70f6 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 13/22] feat: add the Segments list and detail pages with duplicate, delete, add-to-campaign and a pinned-contacts panel, register the routes, the sidebar entry and the breadcrumb labels --- web/src/app/app/segments/[id]/page.tsx | 248 ++++++++++++++++++ web/src/app/app/segments/page.tsx | 226 ++++++++++++++++ web/src/components/layout/AppHeader.tsx | 1 + web/src/components/layout/AppNav.tsx | 2 + .../components/layout/DynamicBreadcrumb.tsx | 1 + web/src/main.tsx | 9 + 6 files changed, 487 insertions(+) create mode 100644 web/src/app/app/segments/[id]/page.tsx create mode 100644 web/src/app/app/segments/page.tsx diff --git a/web/src/app/app/segments/[id]/page.tsx b/web/src/app/app/segments/[id]/page.tsx new file mode 100644 index 00000000..1bf071e8 --- /dev/null +++ b/web/src/app/app/segments/[id]/page.tsx @@ -0,0 +1,248 @@ +// One segment: its definition up top, its live contact list below (the +// shared ContactsTable scoped by segment_ids, with include/exclude actions). + +import React from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import toast from "react-hot-toast"; + +import ContactsTable from "@/components/app/contacts/ContactsTable"; +import SegmentEditor from "@/components/app/segments/SegmentEditor"; +import AddSegmentToCampaignDialog from "@/components/app/segments/AddSegmentToCampaignDialog"; +import { EmptyBlock } from "@/components/layout/Page"; +import { NoAccess } from "@/components/layout/NoAccess"; +import { useConfirm } from "@/hooks/context/confirm"; +import { usePermission, useWriteGuard } from "@/hooks/usePermission"; +import { useDeleteSegment, useSegment, useSegmentFields, useSegmentOverrides, useSetSegmentMembers } from "@/lib/api/hooks/app/segments"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import { + SEGMENT_OPERATORS, + VALUELESS_OPERATORS, + type SegmentCondition, + type SegmentFieldSpec, + type SegmentOverride, +} from "@/lib/api/models/app/segments/Segment"; +import { cn } from "@/lib/utils"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function SegmentPage() { + const canView = usePermission("VIEW_CONTACTS"); + if (!canView) return ; + return ; +} + +function SegmentDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const confirm = useConfirm(); + const write = useWriteGuard("MANAGE_CONTACTS"); + const segment = useSegment(id); + const fields = useSegmentFields(); + const remove = useDeleteSegment(); + const [editorOpen, setEditorOpen] = React.useState(false); + const [campaignOpen, setCampaignOpen] = React.useState(false); + + if (segment.isPending) { + return ( +
+
+
+
+ ); + } + if (segment.isError || !segment.data) { + return ( +
+ + Segments + + +
+ ); + } + const s = segment.data; + + function askDelete() { + confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => { + try { + await remove.mutateAsync(s.id); + toast.success("Segment deleted"); + navigate("/app/segments"); + } catch (err) { + toast.error(buildError(err as AppError)); + } + }); + } + + return ( +
+
+ + Segments + +
+
+
+ +

{s.name}

+ + {s.contact_count.toLocaleString()} contact{s.contact_count === 1 ? "" : "s"} + +
+ {s.description &&

{s.description}

} + +
+
+ + + +
+
+
+ + {(s.included_count > 0 || s.excluded_count > 0) && } + + + + setEditorOpen(false)} segment={s} /> + setCampaignOpen(false)} segment={s} /> +
+ ); +} + +// Pinned contacts. Excluded ones never show in the member list, so this is +// the only place they can be seen and released. +function OverridesPanel({ segment }: { segment: Segment }) { + const write = useWriteGuard("MANAGE_CONTACTS"); + const overrides = useSegmentOverrides(segment.id); + const set = useSetSegmentMembers(); + const [open, setOpen] = React.useState(false); + const [busyId, setBusyId] = React.useState(null); + + async function clear(o: SegmentOverride) { + setBusyId(o.contact_id); + try { + await set.mutateAsync({ id: segment.id, contacts: [o.contact_id], mode: "auto" }); + toast.success("Back to automatic"); + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setBusyId(null); + } + } + + const list = overrides.data ?? []; + return ( +
+ + {open && ( +
    + {overrides.isPending &&
  • Loading…
  • } + {list.map((o) => { + const name = `${o.first_name} ${o.last_name}`.trim() || o.email; + return ( +
  • + + {o.mode === "include" ? "in" : "out"} + + {name} + {o.email} + +
  • + ); + })} +
+ )} +
+ ); +} + +function describe(c: SegmentCondition, specs: SegmentFieldSpec[]): string { + const spec = specs.find((f) => f.field === c.field); + const label = spec?.label ?? c.field.replace(/^custom\./, ""); + const op = spec ? SEGMENT_OPERATORS[spec.kind].find((o) => o.id === c.operator)?.label : c.operator; + if (VALUELESS_OPERATORS.has(c.operator)) return `${label} ${op ?? c.operator}`; + if (c.values && c.values.length > 0) return `${label} ${op} ${c.values.length} value${c.values.length === 1 ? "" : "s"}`; + if (c.operator === "within_days" || c.operator === "not_within_days") return `${label} ${op} ${c.value} days`; + return `${label} ${op} ${c.value ?? ""}`.trim(); +} + +function ConditionSummary({ + conditions, + match, + specs, + included, + excluded, +}: { + conditions: SegmentCondition[]; + match: "all" | "any"; + specs: SegmentFieldSpec[]; + included: number; + excluded: number; +}) { + return ( +
+ {conditions.length === 0 ? ( + No conditions: only contacts added by hand. + ) : ( + conditions.map((c, i) => ( + + {i > 0 && {match === "all" ? "and" : "or"}} + {describe(c, specs)} + + )) + )} + {included > 0 && ( + +{included} added by hand + )} + {excluded > 0 && ( + {excluded} excluded + )} +
+ ); +} diff --git a/web/src/app/app/segments/page.tsx b/web/src/app/app/segments/page.tsx new file mode 100644 index 00000000..c797a23f --- /dev/null +++ b/web/src/app/app/segments/page.tsx @@ -0,0 +1,226 @@ +// Segments: saved contact audiences (issue #266). Each row is one segment +// with its live member count; clicking opens the segment's contact list. + +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, StatStrip, Stat, TopbarAction } from "@/components/layout/Page"; +import { NoAccess } from "@/components/layout/NoAccess"; +import { SearchInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import SegmentEditor from "@/components/app/segments/SegmentEditor"; +import AddSegmentToCampaignDialog from "@/components/app/segments/AddSegmentToCampaignDialog"; +import { useConfirm } from "@/hooks/context/confirm"; +import { usePermission, useWriteGuard } from "@/hooks/usePermission"; +import { useCreateSegment, useDeleteSegment, useSegments } from "@/lib/api/hooks/app/segments"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function SegmentsPage() { + const canView = usePermission("VIEW_CONTACTS"); + if (!canView) return ; + return ; +} + +function SegmentsList() { + const navigate = useNavigate(); + const confirm = useConfirm(); + const write = useWriteGuard("MANAGE_CONTACTS"); + // Menu items and topbar actions take a bare () => void, so give the + // permission guard an empty event to swallow. + const guarded = (fn: () => void) => () => write.guard(fn)({}); + const segments = useSegments(); + const remove = useDeleteSegment(); + const create = useCreateSegment(); + + async function duplicate(s: Segment) { + try { + const copy = await create.mutateAsync({ + name: `${s.name} (copy)`.slice(0, 120), + description: s.description, + color: s.color, + match: s.match, + conditions: s.conditions, + }); + toast.success(`Duplicated as ${copy.name}`); + navigate(`/app/segments/${copy.id}`); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + const [query, setQuery] = React.useState(""); + const [editorOpen, setEditorOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const [campaignFor, setCampaignFor] = React.useState(null); + + const list = React.useMemo(() => { + const all = segments.data ?? []; + const q = query.trim().toLowerCase(); + if (!q) return all; + return all.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)); + }, [segments.data, query]); + + const totals = React.useMemo(() => { + const all = segments.data ?? []; + return { + segments: all.length, + contacts: all.reduce((n, s) => n + s.contact_count, 0), + manual: all.reduce((n, s) => n + s.included_count + s.excluded_count, 0), + largest: all.reduce((best, s) => (!best || s.contact_count > best.contact_count ? s : best), null), + }; + }, [segments.data]); + + function openNew() { + setEditing(null); + setEditorOpen(true); + } + + function openEdit(s: Segment) { + setEditing(s); + setEditorOpen(true); + } + + function askDelete(s: Segment) { + confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => { + try { + await remove.mutateAsync(s.id); + toast.success("Segment deleted"); + } catch (err) { + toast.error(buildError(err as AppError)); + } + }); + } + + return ( + + + } onClick={guarded(openNew)}> + New segment + + + + + + 0} /> + + + + + + + + + {segments.isPending ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : segments.isError ? ( + + ) : list.length === 0 ? ( + } onClick={guarded(openNew)}> + New segment + + ) + } + /> + ) : ( +
+ {list.map((s) => ( +
navigate(`/app/segments/${s.id}`)} + onKeyDown={(e) => { + if (e.key === "Enter") navigate(`/app/segments/${s.id}`); + }} + className="group h-11 px-5 flex items-center gap-3 border-b border-slate-200/60 transition-colors hover:bg-slate-50/80 cursor-pointer" + > + +
+
+ {s.name} + {s.conditions.length === 0 && ( + + manual + + )} +
+ {s.description &&
{s.description}
} +
+ + {s.conditions.length} condition{s.conditions.length === 1 ? "" : "s"} + {s.match === "any" && s.conditions.length > 1 ? " · any" : ""} + + + {s.contact_count.toLocaleString()} + + + contacts + + + + + + + navigate(`/app/segments/${s.id}`)}>View contacts + openEdit(s))}>Edit conditions + setCampaignFor(s)}>Add to campaign + duplicate(s))}>Duplicate + + askDelete(s))}>Delete + + +
+ ))} +
+ )} + + + setEditorOpen(false)} + segment={editing} + onSaved={(saved) => { + if (!editing) navigate(`/app/segments/${saved.id}`); + }} + /> + {campaignFor && ( + setCampaignFor(null)} segment={campaignFor} /> + )} + + ); +} diff --git a/web/src/components/layout/AppHeader.tsx b/web/src/components/layout/AppHeader.tsx index 502f5cc2..128e3213 100644 --- a/web/src/components/layout/AppHeader.tsx +++ b/web/src/components/layout/AppHeader.tsx @@ -33,6 +33,7 @@ const labelMap: Record = { emails: "Accounts", unibox: "Inbox", contacts: "Contacts", + segments: "Segments", campaigns: "Campaigns", analytics: "Analytics", crm: "CRM", diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 8c082410..c1c8f6d3 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -25,6 +25,7 @@ import { SettingsIcon, ShieldCheckIcon, UsersIcon, + LayersIcon, LockIcon, XIcon, ZapIcon, @@ -140,6 +141,7 @@ const sections: NavSection[] = [ { title: "Accounts", url: "/app/emails", icon: MailIcon, indicator: "accounts", advisorSurface: "emails", permission: "MANAGE_EMAILS", permissionLabel: "Manage mailboxes" }, { title: "Campaigns", requires: "subscription", url: "/app/campaigns", icon: MegaphoneIcon, indicator: "campaigns", advisorSurface: "campaigns", permission: "VIEW_CAMPAIGNS", permissionLabel: "View campaigns" }, { title: "Contacts", requires: "subscription", url: "/app/contacts", icon: UsersIcon, indicator: "contacts", advisorSurface: "contacts", permission: "VIEW_CONTACTS", permissionLabel: "View contacts" }, + { title: "Segments", requires: "subscription", url: "/app/segments", icon: LayersIcon, permission: "VIEW_CONTACTS", permissionLabel: "View contacts" }, { title: "Analytics", requires: "subscription", url: "/app/analytics", icon: BarChart3Icon, indicator: "analytics", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" }, { title: "Deliverability", requires: "subscription", url: "/app/deliverability", icon: ShieldCheckIcon, advisorSurface: "deliverability", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" }, ], diff --git a/web/src/components/layout/DynamicBreadcrumb.tsx b/web/src/components/layout/DynamicBreadcrumb.tsx index bdd919d9..c1eac11c 100644 --- a/web/src/components/layout/DynamicBreadcrumb.tsx +++ b/web/src/components/layout/DynamicBreadcrumb.tsx @@ -5,6 +5,7 @@ const labelMap: Record = { app: 'Dashboard', emails: 'Accounts', contacts: 'Contacts', + segments: 'Segments', campaigns: 'Campaigns', unibox: 'Inbox', analytics: 'Analytics', diff --git a/web/src/main.tsx b/web/src/main.tsx index c74ef23b..c243b008 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -12,6 +12,8 @@ import "@fontsource/poppins/700.css"; import RootAppLayout from './app/app/layout'; import AddressesPage from './app/app/emails/page'; import ContactsPage from './app/app/contacts/page'; +import SegmentsPage from './app/app/segments/page'; +import SegmentPage from './app/app/segments/[id]/page'; import CampaignsPage from './app/app/campaigns/page'; import CampaignLayout from './app/app/campaigns/[id]/layout'; import CampaignPreview from './app/app/campaigns/[id]/page'; @@ -237,6 +239,13 @@ const router = createBrowserRouter([ path: "contacts", element: , }, + { + path: "segments", + children: [ + { index: true, element: }, + { path: ":id", element: }, + ], + }, { path: "campaigns", children: [ From ad40be927633c64955092c82b91b484803598eb0 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:05 -0700 Subject: [PATCH 14/22] feat: scope the contacts table by segment, add the Segment and Remove-from-segment selection bar actions, let the add-from-contacts dialog target a segment, and add the add-to-campaign and from-segment lead dialogs --- .../app/contacts/AddFromContactsDialog.tsx | 55 +++-- .../components/app/contacts/ContactsTable.tsx | 92 +++++++- .../app/segments/AddSegmentLeadsDialog.tsx | 193 +++++++++++++++++ .../segments/AddSegmentToCampaignDialog.tsx | 200 ++++++++++++++++++ .../app/segments/AddToSegmentMenu.tsx | 63 ++++++ 5 files changed, 581 insertions(+), 22 deletions(-) create mode 100644 web/src/components/app/segments/AddSegmentLeadsDialog.tsx create mode 100644 web/src/components/app/segments/AddSegmentToCampaignDialog.tsx create mode 100644 web/src/components/app/segments/AddToSegmentMenu.tsx diff --git a/web/src/components/app/contacts/AddFromContactsDialog.tsx b/web/src/components/app/contacts/AddFromContactsDialog.tsx index 05cca5be..d5b89178 100644 --- a/web/src/components/app/contacts/AddFromContactsDialog.tsx +++ b/web/src/components/app/contacts/AddFromContactsDialog.tsx @@ -21,6 +21,7 @@ import { SearchInput } from "@/components/ui/field"; import CategoryPicker from "./CategoryPicker"; import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts"; import useUpdateContactsBulk from "@/lib/api/hooks/app/contacts/useUpdateContactsBulk"; +import { useSetSegmentMembers } from "@/lib/api/hooks/app/segments"; import searchContacts from "@/lib/api/client/app/contacts/searchContacts"; import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; import type Contact from "@/lib/api/models/app/contacts/Contact"; @@ -33,10 +34,17 @@ import { cn, hexToRgba } from "@/lib/utils"; const PAGE = 100; const MAX_SELECTION = 1000; +// The target is either a campaign (contacts become leads) or a segment +// (contacts are pinned in as manual includes). +export type AddFromContactsTarget = + | { kind: "campaign"; campaign: MiniCampaign } + | { kind: "segment"; segment: { id: string; name: string } }; + interface Props { open: boolean; onClose: () => void; - campaign: MiniCampaign; + campaign?: MiniCampaign; + target?: AddFromContactsTarget; } function displayName(c: Contact): string { @@ -44,8 +52,14 @@ function displayName(c: Contact): string { return n || c.email; } -export default function AddFromContactsDialog({ open, onClose, campaign }: Props) { +export default function AddFromContactsDialog({ open, onClose, campaign: campaignProp, target: targetProp }: Props) { const bulk = useUpdateContactsBulk(); + const members = useSetSegmentMembers(); + const target: AddFromContactsTarget = React.useMemo( + () => targetProp ?? { kind: "campaign", campaign: campaignProp as MiniCampaign }, + [targetProp, campaignProp], + ); + const targetName = target.kind === "campaign" ? target.campaign.name : target.segment.name; const [query, setQuery] = React.useState(""); const [categoryIds, setCategoryIds] = React.useState([]); @@ -87,8 +101,8 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props }, [open]); const inCampaign = React.useCallback( - (c: Contact) => (c.campaigns ?? []).some((x) => x.id === campaign.id), - [campaign.id], + (c: Contact) => target.kind === "campaign" && (c.campaigns ?? []).some((x) => x.id === target.campaign.id), + [target], ); const selectable = React.useMemo(() => contacts.filter((c) => !inCampaign(c)), [contacts, inCampaign]); @@ -143,22 +157,27 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props } async function submit() { - if (bulk.isPending || selected.size === 0) return; + if (busy || selected.size === 0) return; try { - await bulk.mutateAsync({ - contacts: [...selected], - add_campaigns: [campaign.id], - remove_campaigns: [], - fields: [], - }); - toast.success(`Added ${selected.size} lead${selected.size === 1 ? "" : "s"} to ${campaign.name}`); + if (target.kind === "campaign") { + await bulk.mutateAsync({ + contacts: [...selected], + add_campaigns: [target.campaign.id], + remove_campaigns: [], + fields: [], + }); + toast.success(`Added ${selected.size} lead${selected.size === 1 ? "" : "s"} to ${target.campaign.name}`); + } else { + await members.mutateAsync({ id: target.segment.id, contacts: [...selected], mode: "include" }); + toast.success(`Added ${selected.size} contact${selected.size === 1 ? "" : "s"} to ${target.segment.name}`); + } onClose(); } catch (err) { toast.error(buildError(err as AppError)); } } - const busy = bulk.isPending; + const busy = bulk.isPending || members.isPending; const requestClose = React.useCallback(() => { if (!busy) onClose(); }, [busy, onClose]); @@ -206,12 +225,12 @@ export default function AddFromContactsDialog({ open, onClose, campaign }: Props
- Add leads + {target.kind === "campaign" ? "Add leads" : "Add contacts"}
From contacts - → {campaign.name} + → {targetName} diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 1f9ec637..0b085590 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -20,6 +20,7 @@ import { ClockIcon, CornerUpLeftIcon, DownloadIcon, + LayersIcon, Loader2Icon, MailIcon, MailOpenIcon, @@ -35,6 +36,7 @@ import { UploadIcon, UserPlusIcon, UsersIcon, + XIcon, } from "lucide-react"; import { useConfirm } from "@/hooks/context/confirm"; @@ -63,6 +65,9 @@ import { NewContactDialog } from "./NewContactDialog"; import ExportDialog from "./ExportDialog"; import ImportWizard from "./ImportWizard"; import AddFromContactsDialog from "./AddFromContactsDialog"; +import AddToSegmentMenu from "@/components/app/segments/AddToSegmentMenu"; +import AddSegmentLeadsDialog from "@/components/app/segments/AddSegmentLeadsDialog"; +import { useSetSegmentMembers } from "@/lib/api/hooks/app/segments"; import useAiMetered from "@/hooks/useAiMetered"; import SyncSourcesPanel from "./SyncSourcesPanel"; import { CategoryChip } from "./CategoryPicker"; @@ -92,10 +97,14 @@ type SubFilter = "all" | "subscribed" | "unsubscribed"; export default function ContactsTable({ current_campaign, + segment, }: { current_campaign?: MiniCampaign; + // Scope the list to one segment's members (the segment detail page). + segment?: { id: string; name: string }; }) { const confirm = useConfirm(); + const segmentMembers = useSetSegmentMembers(); const [selected, setSelected] = React.useState([]); const [del, setDelete] = React.useState(false); const [filtersOpen, setFiltersOpen] = React.useState(false); @@ -114,15 +123,30 @@ export default function ContactsTable({ const [importOpen, setImportOpen] = React.useState(false); const [syncOpen, setSyncOpen] = React.useState(false); const [fromContactsOpen, setFromContactsOpen] = React.useState(false); + const [fromSegmentOpen, setFromSegmentOpen] = React.useState(false); const [searchProps, setSearchProps] = React.useState({ query: "", filters: [], campaign_ids: current_campaign ? [current_campaign.id] : [], + segment_ids: segment ? [segment.id] : undefined, sort_by: "created_at", reverse: false, }); const contactsData = useSearchContacts({ options: searchProps }); + + // Inside a segment, "remove" pins the contact out as a manual exclude so + // it stays out even while the conditions still match it. + async function excludeFromSegment() { + if (!segment || selected.length === 0 || segmentMembers.isPending) return; + try { + await segmentMembers.mutateAsync({ id: segment.id, contacts: selected, mode: "exclude" }); + toast.success(`Removed ${selected.length} contact${selected.length === 1 ? "" : "s"} from ${segment.name}`); + setSelected([]); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } const contactsBulkDelete = useDeleteContacts(); // Connected CRM targets the "Push to CRM" bulk action can reach. Driven by @@ -314,6 +338,13 @@ export default function ContactsTable({ > From contacts + } + onClick={() => setFromSegmentOpen(true)} + > + From segment + } @@ -409,6 +440,10 @@ export default function ContactsTable({ ) } onClear={() => setSelected([])} + selected={selected} + segment={segment} + onExclude={excludeFromSegment} + excluding={segmentMembers.isPending} /> setFromContactsOpen(false)} campaign={current_campaign} /> + setFromSegmentOpen(false)} + campaign={current_campaign} + /> ); } @@ -447,15 +487,26 @@ export default function ContactsTable({ return ( + {segment && ( + } + onClick={() => setFromContactsOpen(true)} + > + Add contacts + + )}
- + {!segment && - + } setSelected([])} + selected={selected} + segment={segment} + onExclude={excludeFromSegment} + excluding={segmentMembers.isPending} /> {filtered.length === 0 && !contactsData.isPending ? null : null} @@ -635,6 +690,13 @@ export default function ContactsTable({ setNewOpen(false)} /> + {segment && ( + setFromContactsOpen(false)} + target={{ kind: "segment", segment }} + /> + )} setExportOpen(false)} @@ -1321,6 +1383,10 @@ function SelectionBar({ researching, onDelete, onClear, + selected, + segment, + onExclude, + excluding, }: { count: number; deleting: boolean; @@ -1332,6 +1398,10 @@ function SelectionBar({ researching: boolean; onDelete: () => void; onClear: () => void; + selected: string[]; + segment?: { id: string; name: string }; + onExclude: () => void; + excluding: boolean; }) { if (count === 0) return null; return ( @@ -1382,6 +1452,18 @@ function SelectionBar({ > Edit + + {segment && ( + + )} + +
+ +
+
+ {segments.isPending ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : list.length === 0 ? ( +
+

{query ? "No segments match" : "No segments yet"}

+

Build one from the Segments page first.

+
+ ) : ( +
    + {list.map((s) => { + const on = picked === s.id; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ Adds today's members. Existing leads are skipped. + + +
+ + + )} + + ); +} diff --git a/web/src/components/app/segments/AddSegmentToCampaignDialog.tsx b/web/src/components/app/segments/AddSegmentToCampaignDialog.tsx new file mode 100644 index 00000000..49c2bc4e --- /dev/null +++ b/web/src/components/app/segments/AddSegmentToCampaignDialog.tsx @@ -0,0 +1,200 @@ +// Enrol a segment's current members as leads of one campaign. A snapshot: +// contacts who join the segment later are not added automatically. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Loader2Icon, MegaphoneIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { SearchInput } from "@/components/ui/field"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import { useAddSegmentToCampaign } from "@/lib/api/hooks/app/segments"; +import type Segment from "@/lib/api/models/app/segments/Segment"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import { cn } from "@/lib/utils"; + +export default function AddSegmentToCampaignDialog({ + open, + onClose, + segment, +}: { + open: boolean; + onClose: () => void; + segment: Segment; +}) { + const add = useAddSegmentToCampaign(); + const [query, setQuery] = React.useState(""); + const [picked, setPicked] = React.useState(null); + const campaigns = useCampaigns({ query: query.trim(), folder: "", enabled: open }); + + React.useEffect(() => { + if (!open) { + setQuery(""); + setPicked(null); + } + }, [open]); + + const busy = add.isPending; + const requestClose = React.useCallback(() => { + if (!busy) onClose(); + }, [busy, onClose]); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (document.querySelector("[data-floating], [role='alertdialog']")) return; + requestClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, requestClose]); + + async function submit() { + if (!picked || busy) return; + const target = campaigns.campaigns.find((c) => c.id === picked); + try { + const res = await add.mutateAsync({ id: segment.id, campaignId: picked }); + toast.success( + res.added === 0 + ? `Every member of ${segment.name} is already a lead in ${target?.name ?? "the campaign"}` + : `Added ${res.added.toLocaleString()} lead${res.added === 1 ? "" : "s"} to ${target?.name ?? "the campaign"}`, + ); + onClose(); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + return ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[520px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[80dvh]" + > +
+
+ +
+ Add to campaign +
+ {segment.name} + + {segment.contact_count.toLocaleString()} contact{segment.contact_count === 1 ? "" : "s"} + + +
+
+ +
+
+ {campaigns.isPending ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : campaigns.campaigns.length === 0 ? ( +
+

No campaigns found

+

Create a campaign first, then add this segment to it.

+
+ ) : ( +
    + {campaigns.campaigns.map((c) => { + const on = picked === c.id; + return ( +
  • + +
  • + ); + })} + {campaigns.hasNextPage && ( +
  • + +
  • + )} +
+ )} +
+
+ + Adds today's members. Contacts already in the campaign are skipped. + + + +
+ + + )} + + ); +} diff --git a/web/src/components/app/segments/AddToSegmentMenu.tsx b/web/src/components/app/segments/AddToSegmentMenu.tsx new file mode 100644 index 00000000..02e14b62 --- /dev/null +++ b/web/src/components/app/segments/AddToSegmentMenu.tsx @@ -0,0 +1,63 @@ +// "Add to segment" popover for the contacts selection bar: pins the selected +// contacts into a segment as manual includes (or, inside a segment's own +// list, excludes them / clears the override). + +import React from "react"; +import { Loader2Icon, LayersIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { useSegments, useSetSegmentMembers } from "@/lib/api/hooks/app/segments"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function AddToSegmentMenu({ contacts, onDone }: { contacts: string[]; onDone?: () => void }) { + const segments = useSegments(); + const set = useSetSegmentMembers(); + + async function add(id: string, name: string) { + try { + await set.mutateAsync({ id, contacts, mode: "include" }); + toast.success(`Added ${contacts.length} contact${contacts.length === 1 ? "" : "s"} to ${name}`); + onDone?.(); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + const list = segments.data ?? []; + return ( + + + + + + Add {contacts.length} to segment + {list.length === 0 && ( +
No segments yet. Create one from the Segments page.
+ )} + {list.map((s) => ( + add(s.id, s.name)}> + + + {s.name} + + + ))} +
+
+ ); +} From 716186d50365b8e55ab209d628ad9d96c02f47ea Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:06 -0700 Subject: [PATCH 15/22] feat: add a Segments picker to the contact filters drawer and count a segment scope as an active filter for exports --- .../components/app/contacts/ContactFilters.tsx | 18 ++++++++++++++++++ .../components/app/contacts/ExportDialog.tsx | 1 + 2 files changed, 19 insertions(+) diff --git a/web/src/components/app/contacts/ContactFilters.tsx b/web/src/components/app/contacts/ContactFilters.tsx index 2cd73b37..a61c574b 100644 --- a/web/src/components/app/contacts/ContactFilters.tsx +++ b/web/src/components/app/contacts/ContactFilters.tsx @@ -41,6 +41,7 @@ import { } from "@/components/ui/popover-menu"; import { SectionBar } from "@/components/layout/Page"; import CategoryPicker from "./CategoryPicker"; +import { SegmentMultiPicker } from "@/components/app/segments/SegmentPickers"; interface Props { active: boolean; @@ -267,6 +268,22 @@ export default function ContactFilters({

+
+
+ + setDraft((s) => ({ + ...s, + segment_ids: next.length > 0 ? next : undefined, + })) + } + /> +

+ Contacts must be members of every selected segment. +

+
+ {activeCampaign && ( <>
@@ -640,5 +657,6 @@ function countActiveFilters(f: SearchContacts, hasCampaignContext: boolean): num // Don't count campaign scoping if it's coming from an outer page context. if (!hasCampaignContext && f.campaign_ids.length > 0) n++; if (f.category_ids && f.category_ids.length > 0) n++; + if (f.segment_ids && f.segment_ids.length > 0) n++; return n; } diff --git a/web/src/components/app/contacts/ExportDialog.tsx b/web/src/components/app/contacts/ExportDialog.tsx index af540e64..7618c072 100644 --- a/web/src/components/app/contacts/ExportDialog.tsx +++ b/web/src/components/app/contacts/ExportDialog.tsx @@ -99,6 +99,7 @@ function hasActiveFilters(f: SearchContacts): boolean { f.filters.length > 0 || (f.campaign_ids?.length ?? 0) > 0 || (f.category_ids?.length ?? 0) > 0 || + (f.segment_ids?.length ?? 0) > 0 || f.subscribed !== undefined || !!f.lead_status || !!f.engagement || From b638ac6b245dad826cde405077c5a7b32b8ca318 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:06 -0700 Subject: [PATCH 16/22] feat: show every segment with membership and pin in / pin out / back-to-automatic controls in the contact drawer overview --- .../contact-edit/ContactSegmentsSection.tsx | 122 ++++++++++++++++++ .../app/contacts/contact-edit/OverviewTab.tsx | 3 + 2 files changed, 125 insertions(+) create mode 100644 web/src/components/app/contacts/contact-edit/ContactSegmentsSection.tsx diff --git a/web/src/components/app/contacts/contact-edit/ContactSegmentsSection.tsx b/web/src/components/app/contacts/contact-edit/ContactSegmentsSection.tsx new file mode 100644 index 00000000..265faf88 --- /dev/null +++ b/web/src/components/app/contacts/contact-edit/ContactSegmentsSection.tsx @@ -0,0 +1,122 @@ +// Segments panel of the contact drawer: which segments this contact is in, +// with the manual override (pin in / pin out / back to automatic) per row. + +import React from "react"; +import { CheckIcon, Loader2Icon, MinusIcon, RotateCcwIcon } from "lucide-react"; +import { Link } from "react-router-dom"; +import toast from "react-hot-toast"; + +import { useWriteGuard } from "@/hooks/usePermission"; +import { useContactSegments, useSetSegmentMembers } from "@/lib/api/hooks/app/segments"; +import type { ContactSegment, SegmentMemberMode } from "@/lib/api/models/app/segments/Segment"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import { cn } from "@/lib/utils"; + +export function ContactSegmentsSection({ contactId }: { contactId: string }) { + const segments = useContactSegments(contactId); + const set = useSetSegmentMembers(); + const write = useWriteGuard("MANAGE_CONTACTS"); + const [busyId, setBusyId] = React.useState(null); + + async function apply(seg: ContactSegment, mode: SegmentMemberMode) { + if (set.isPending) return; + setBusyId(seg.id); + try { + await set.mutateAsync({ id: seg.id, contacts: [contactId], mode }); + toast.success( + mode === "include" ? `Pinned into ${seg.name}` : mode === "exclude" ? `Pinned out of ${seg.name}` : `${seg.name} is automatic again`, + ); + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setBusyId(null); + } + } + + const list = segments.data ?? []; + const members = list.filter((s) => s.member); + const others = list.filter((s) => !s.member); + + return ( +
+

+ Segments + {list.length > 0 && {members.length} of {list.length}} +

+
+ {segments.isPending ? ( +
Loading…
+ ) : list.length === 0 ? ( +
+ No segments yet.{" "} + + Create one + + . +
+ ) : ( +
    + {[...members, ...others].map((s) => { + const busy = busyId === s.id; + return ( +
  • + + + {s.name} + + {s.mode === "include" && ( + pinned in + )} + {s.mode === "exclude" && ( + pinned out + )} +
    + {busy ? ( + + ) : ( + <> + {s.mode !== undefined && ( + apply(s, "auto"))}> + + + )} + {s.mode !== "include" && ( + apply(s, "include"))}> + + + )} + {s.mode !== "exclude" && ( + apply(s, "exclude"))}> + + + )} + + )} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} + +function IconButton({ title, onClick, children }: { title: string; onClick: (e: React.MouseEvent) => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index f28b7042..cbeba784 100644 --- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx +++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx @@ -20,6 +20,7 @@ import type ContactDetail from "@/lib/api/models/app/contacts/ContactDetail"; import type Contact from "@/lib/api/models/app/contacts/Contact"; import { fmtAbsolute, fmtRelative } from "./format"; import { sourceLabel } from "./ActivityTab"; +import { ContactSegmentsSection } from "./ContactSegmentsSection"; export default function OverviewTab({ contact, @@ -179,6 +180,8 @@ export default function OverviewTab({
+ + {detail && (
From 15dface385ab34a248d8909962a59477302aa4de Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:06 -0700 Subject: [PATCH 17/22] feat: add the Add to segment and Remove from segment action nodes to the campaign sequence canvas with a segment picker --- .../app/campaigns/sequences/CampaignFlow.tsx | 27 +++++++++++++++++++ .../models/app/campaigns/sequences/Action.ts | 5 ++++ 2 files changed, 32 insertions(+) diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 11866401..980e0efc 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -37,6 +37,7 @@ import { SparklesIcon, SplitIcon, TagIcon, + LayersIcon, TagsIcon, Trash2Icon, UnlinkIcon, @@ -95,6 +96,7 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import StepEmailArms from "./StepEmailArms"; import CategoryPicker from "@/components/app/contacts/CategoryPicker"; +import { SegmentMultiPicker } from "@/components/app/segments/SegmentPickers"; import type { ActionKV, AITagRef, SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action"; import { useAutomations } from "@/lib/api/hooks/app/automations/useAutomations"; import { triggerLabel } from "@/lib/api/models/app/automations/meta"; @@ -441,6 +443,8 @@ function StopNode() { const ACTION_META: Record = { add_tag: { label: "Add tag", Icon: TagIcon, tint: "text-emerald-600" }, remove_tag: { label: "Remove tag", Icon: TagIcon, tint: "text-amber-600" }, + add_to_segment: { label: "Add to segment", Icon: LayersIcon, tint: "text-emerald-600" }, + remove_from_segment: { label: "Remove from segment", Icon: LayersIcon, tint: "text-amber-600" }, label_email: { label: "Label email", Icon: TagsIcon, tint: "text-fuchsia-600" }, create_task: { label: "Create task", Icon: CheckSquareIcon, tint: "text-violet-600" }, create_deal: { label: "Create deal", Icon: HandshakeIcon, tint: "text-emerald-600" }, @@ -460,6 +464,10 @@ function actionSummary(a?: SequenceAction | null): string { return a.category_id ? "Add a tag" : "Pick a tag…"; case "remove_tag": return a.category_id ? "Remove a tag" : "Pick a tag…"; + case "add_to_segment": + return a.segment_id ? "Pin into a segment" : "Pick a segment…"; + case "remove_from_segment": + return a.segment_id ? "Pin out of a segment" : "Pick a segment…"; case "label_email": return a.label_ids && a.label_ids.length ? "Label the conversation" : "Pick a label…"; case "create_deal": @@ -2443,6 +2451,8 @@ function ConnectionEditor({ const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [ { type: "add_tag", label: "Add tag" }, { type: "remove_tag", label: "Remove tag" }, + { type: "add_to_segment", label: "Add to segment" }, + { type: "remove_from_segment", label: "Remove from segment" }, { type: "label_email", label: "Label email" }, { type: "create_task", label: "Create task" }, { type: "create_deal", label: "Create deal" }, @@ -2891,6 +2901,23 @@ function ActionConfigFields({
)} + {(action.type === "add_to_segment" || action.type === "remove_from_segment") && ( +
+ + + setAction((a) => ({ ...a, segment_id: ids.length ? ids[ids.length - 1] : null })) + } + /> +

+ {action.type === "add_to_segment" + ? "The contact stays in the segment whatever its conditions say." + : "The contact stays out of the segment even while its conditions match."} +

+
+ )} + {action.type === "label_email" && (
diff --git a/web/src/lib/api/models/app/campaigns/sequences/Action.ts b/web/src/lib/api/models/app/campaigns/sequences/Action.ts index d6bd7bc0..d7b2e389 100644 --- a/web/src/lib/api/models/app/campaigns/sequences/Action.ts +++ b/web/src/lib/api/models/app/campaigns/sequences/Action.ts @@ -5,6 +5,8 @@ export type SequenceActionType = | "add_tag" | "remove_tag" + | "add_to_segment" + | "remove_from_segment" | "label_email" | "unsubscribe" | "create_task" @@ -26,6 +28,9 @@ export interface SequenceAction { type: SequenceActionType; // add_tag / remove_tag — a contact category id category_id?: string | null; + // add_to_segment / remove_from_segment — pins the contact into or out of + // a segment as a manual override + segment_id?: string | null; // label_email — unibox conversation labels (same category registry as tags) // applied to the thread the contact replied on. Reply-branch only; a no-op // when the contact has not replied. From abd2ebca9748b6f86544d3799fc9fbf3344ab1cd Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:06 -0700 Subject: [PATCH 18/22] feat: document segments in a new guide (building, overrides, sequence actions, using, limits), register it in the guides index and link it from the contacts guide --- docs/content/docs/guides/contacts-crm.mdx | 5 +- docs/content/docs/guides/meta.json | 1 + docs/content/docs/guides/segments.mdx | 69 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/guides/segments.mdx diff --git a/docs/content/docs/guides/contacts-crm.mdx b/docs/content/docs/guides/contacts-crm.mdx index 5e603687..8d6bcfa0 100644 --- a/docs/content/docs/guides/contacts-crm.mdx +++ b/docs/content/docs/guides/contacts-crm.mdx @@ -71,7 +71,9 @@ Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, ` It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each category keeps its color everywhere its chip appears. -Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. +Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/). + +To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (categories, fields, campaign activity, engagement) that you can browse and add to a campaign in one step. ## Where a contact came from @@ -121,6 +123,7 @@ This is the safe default response to a bad signal: stop sending rather than keep ## Where to go next + diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 63afd0eb..59dd1400 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -15,6 +15,7 @@ "advisor", "---Contacts and inbox---", "contacts-crm", + "segments", "website-tracking", "unibox", "meetings", diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx new file mode 100644 index 00000000..ed0fd82f --- /dev/null +++ b/docs/content/docs/guides/segments.mdx @@ -0,0 +1,69 @@ +--- +title: Segments +description: Save reusable audiences built from contact fields, categories, campaign activity and email engagement, then add them to campaigns in one step. +--- + +A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new category, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step. + +Segments live under **Segments** in the sidebar and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete. + +## Building a segment + +Give the segment a name and a color, then add conditions. Each condition is a field, an operator and a value. Pick whether a contact must match **all** conditions or **any** of them. The drawer shows a live count of matching contacts as you edit, so you can see the effect of every change before saving. + +| Group | Fields | Operators | +|-------|--------|-----------| +| Contact | First name, last name, email, email domain, phone, and every custom field | is, is not, contains, does not contain, starts with, ends with, is empty, is not empty | +| Contact | Subscribed, on the suppression list, catch-all domain | is yes, is no | +| Contact | Source, verification status, email provider | is any of, is none of | +| Contact | Created, updated | in the last N days, not in the last N days, after, before | +| Contact | Category | has any of, has none of, has none, has any | +| Company | Company name | the text operators above | +| Campaign activity | In campaign | is in any of, is in none of, is in no campaign, is in a campaign | +| Campaign activity | Number of campaigns | is, is not, more than, at least, less than, at most | +| Email engagement | Emails sent, emails opened, links clicked, replies, bounces | the number operators above | +| Email engagement | Last email sent, last open, last click, last reply | in the last N days, not in the last N days, after, before, never, ever | +| Segments | In segment | is in any of, is in none of | + +Engagement counts add up every campaign the contact has been in. Opens count human opens only; automated fetches are ignored, the same way the campaign analytics report them. Text comparisons ignore case. + +A segment can be built on other segments (**In segment**), up to five levels deep. A segment cannot reference itself or form a loop, and a segment that others depend on cannot be deleted until those references are removed. + +A segment with no conditions is a manual list: it holds only the contacts you add by hand. + +## Adding and removing contacts by hand + +Conditions decide membership, and two overrides sit on top of them: + +- **Add contacts** on a segment page (or **Segment** in the selection bar of any contact list) pins contacts in. They stay members whatever the conditions say. +- **Remove from segment** in the selection bar of a segment's member list pins contacts out. They stay out even while the conditions still match them. + +The segment header shows how many contacts are pinned in or out, and a **Pinned contacts** panel below it lists them; **Back to automatic** clears an override so the conditions decide again. A contact's own drawer has a **Segments** section that shows every segment, whether the contact is in it, and the same pin in, pin out and back-to-automatic controls. + +Sequences can pin as well: the **Add to segment** and **Remove from segment** action steps apply the override to a contact as it moves through a campaign flow, so a positive reply can drop someone into a "warm" segment automatically. + +## Using a segment + +- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page. +- **Add to campaign**: enrols every current member as a lead of the campaign you pick, from the segment page or with **From segment** on a campaign's Leads tab. Contacts already in that campaign are skipped, and a running campaign wakes up to schedule the new leads. This is a snapshot: contacts who join the segment later are not added until you run it again. +- **Filter**: the contacts page filters have a **Segments** picker, and the same scope carries into an export. +- **Duplicate**: the segment menu copies a definition to start a variation from. +- **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment. + + +Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience. + + +## Limits + +- 200 segments per workspace +- 50 conditions per segment, 200 values per list condition +- 1,000 contacts per manual add or remove request + +## Where to go next + + + + + + From 50663ef6529102c729aa8b4e3051302b15f549bb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 23:45:06 -0700 Subject: [PATCH 19/22] feat: document the segments endpoints, condition format, segment_ids search filter and the add/remove-segment sequence actions in the API reference, and note that segments travel in workspace archives --- docs/content/docs/api/endpoints.mdx | 19 ++++ docs/content/docs/api/reference/campaigns.mdx | 2 +- docs/content/docs/api/reference/contacts.mdx | 93 +++++++++++++++++++ .../docs/guides/workspace-export-import.mdx | 2 +- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 7acc0d80..09991215 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -82,6 +82,7 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/ | GET | `/contacts/:id/emails` | `READ_CONTACTS` | | GET | `/contacts/:id/timeline` | `READ_CONTACTS` | | GET | `/contacts/:id/campaigns` | `READ_CONTACTS` | +| GET | `/contacts/:id/segments` | `READ_CONTACTS` | | POST | `/contacts/export` | `READ_CONTACTS` | | POST | `/contacts/import/preview` | `WRITE_CONTACTS` | | POST | `/contacts/import/commit` | `BULK_CONTACTS` | @@ -101,6 +102,24 @@ The import pair is two steps over the same file: preview parses it and suggests AI contact research charges credits (2 per run, billable even when it finds nothing) and only saves cited findings. See the [AI contact research](/guides/ai-contact-research/) guide. The batch endpoint accepts up to 500 contact ids and drains in the background. +### Segments + +| Method | Path | API Permission | +|--------|------|----------------| +| GET | `/segments` | `READ_CONTACTS` | +| GET | `/segments/fields` | `READ_CONTACTS` | +| POST | `/segments/preview` | `READ_CONTACTS` | +| POST | `/segments` | `WRITE_CONTACTS` | +| GET | `/segments/:id` | `READ_CONTACTS` | +| PATCH | `/segments/:id` | `WRITE_CONTACTS` | +| DELETE | `/segments/:id` | `WRITE_CONTACTS` | +| POST | `/segments/:id/members` | `WRITE_CONTACTS` | +| POST | `/segments/:id/members/lookup` | `READ_CONTACTS` | +| GET | `/segments/:id/overrides` | `READ_CONTACTS` | +| POST | `/segments/:id/add-to-campaign` | `WRITE_CAMPAIGNS` | + +Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope. `POST /contacts/search` and `POST /contacts/export` accept `segment_ids` to scope any contact query to a segment. See [contacts](/api/reference/contacts/#segments) for the condition format. + ### Lead sync Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when `/lead-sync/sources/:id/sync` is called. diff --git a/docs/content/docs/api/reference/campaigns.mdx b/docs/content/docs/api/reference/campaigns.mdx index 10249756..7a8f10c7 100644 --- a/docs/content/docs/api/reference/campaigns.mdx +++ b/docs/content/docs/api/reference/campaigns.mdx @@ -874,7 +874,7 @@ All fields optional. | `wait_after` | integer | no | Days to wait before this step, counted from the contact's previous step (`0` to `60`). Spacing belongs to the target step, so there is no standalone wait node for email steps. | | `conditions` | object | no | The connections out of this step (`{branches: [...]}`), evaluated in order; a branch with no `conditions` is a plain "go there next" link. Routing follows connections only: a step with `{}` or no branches has no outgoing path and ends the flow for the contact. | | `kind` | string | no | `email` (default), `action`, or `wait`. | -| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. | +| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `add_to_segment`, `remove_from_segment` (each with a `segment_id`), `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. | ```json { diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index b9e9f61e..40cad460 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -31,6 +31,7 @@ Every field is optional; an empty body matches all contacts in the organization. | `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. | | `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. | | `category_ids` | string[] | No | Contact must have ALL of these categories. | +| `segment_ids` | string[] | No | Contact must be a member of ALL of these segments (conditions plus manual overrides). An id that is not a valid UUID is rejected with `400`; an unknown segment matches nothing. | | `min_campaigns` | integer | No | Minimum number of associated campaigns. | | `max_campaigns` | integer | No | Maximum number of associated campaigns. | | `subscribed` | boolean | No | Filter by subscription status. | @@ -927,3 +928,95 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` ``` `status` is `open`, `won`, or `lost`. `value`, `expected_close_date`, `won_at`, `lost_at`, `lost_reason`, `assigned_to`, `campaign_id`, and `source_mailbox_id` are nullable and omitted when unset. + +## Segments + +Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. + +A segment object: + +```json +{ + "id": "0b6f9c3e-2f7a-4c0e-9d8e-1a2b3c4d5e6f", + "organization_id": "…", + "name": "Warm fintech leads", + "description": "Opened in the last 30 days, not yet replied", + "color": "#0284c7", + "match": "all", + "conditions": [ + { "field": "custom.industry", "operator": "equals", "value": "fintech" }, + { "field": "last_opened_at", "operator": "within_days", "value": "30" }, + { "field": "emails_replied", "operator": "equals", "value": "0" } + ], + "contact_count": 412, + "included_count": 3, + "excluded_count": 1, + "created_at": "2026-08-01T09:12:00Z", + "updated_at": "2026-08-20T14:03:00Z" +} +``` + +`match` is `all` or `any`. A contact is a member when it matches the conditions or is manually included, and is not manually excluded. A segment with no conditions holds only its manual includes. + +### Conditions + +Each condition names a `field`, an `operator`, and either a `value` (scalar operators) or `values` (list operators). Fields and their kinds are returned by `GET /segments/fields`, including the workspace's custom fields as `custom.` (written with the key in place of the angle-bracket placeholder, for example `custom.industry`). + +| Kind | Fields | Operators | Value | +| --- | --- | --- | --- | +| text | `first_name`, `last_name`, `email`, `email_domain`, `phone`, `company`, `custom.*` | `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` | `value` string; comparisons ignore case | +| enum | `source`, `verification_status`, `esp_provider` | `in`, `not_in` | `values`, drawn from the field's `options` | +| bool | `subscribed`, `suppressed`, `is_catch_all` | `is_true`, `is_false` | none | +| date | `created_at`, `updated_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `within_days`, `not_within_days` (`value` is a day count, 1 to 3650); `before`, `after` (`value` is `YYYY-MM-DD` or RFC 3339); `is_empty`, `is_not_empty` | see operators | +| number | `campaign_count`, `emails_sent`, `emails_opened`, `emails_clicked`, `emails_replied`, `emails_bounced` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte` | `value`, a whole number | +| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, category ids | +| campaign | `campaign` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, campaign ids | +| segment | `segment` | `in`, `not_in` | `values`, segment ids; at most five levels deep, no loops | + +Engagement counters add up every campaign the contact has been in, and opens count human opens only. Limits: 50 conditions per segment, 200 values per list condition, 200 segments per workspace. A condition that fails validation is rejected with `400` and a message naming the condition. + +### List, create, read, update, delete + +`GET /segments` returns every segment with live counts under `data`. `POST /segments` creates one from `name` (required), `description`, `color` (`#rrggbb`), `match` and `conditions`; a duplicate name is a `409`. `GET /segments/:id` returns one segment. `PATCH /segments/:id` accepts the same fields, all optional. `DELETE /segments/:id` returns `204`, or `409` when another segment's conditions reference it. + +Auth: **Scope** `READ_CONTACTS` for reads, `WRITE_CONTACTS` for writes · **Org permission** `view_contacts` / `manage_contacts` + +### Preview a definition + +`POST /segments/preview` + +Counts the contacts an unsaved definition would match. Send `match` and `conditions`; include `id` to keep that segment's manual overrides in the count while editing it. + +```json +{ "contact_count": 412 } +``` + +### Manual overrides + +`POST /segments/:id/members` + +```json +{ "contacts": ["…", "…"], "mode": "include" } +``` + +`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 1,000 contact ids per call; ids outside the organization are ignored. Returns `{ "updated": n }`. + +`POST /segments/:id/members/lookup` takes `{ "contacts": [...] }` and returns `{ "data": { "": "include" | "exclude" } }` for the contacts that carry an override. + +`GET /segments/:id/overrides` lists every pinned contact (`contact_id`, `first_name`, `last_name`, `email`, `company`, `mode`, `created_at`) under `data`, includes first, newest first, capped at 500. + +`GET /contacts/:id/segments` is the contact-side view: every segment in the organization with `member` (whether the contact is in it right now) and `mode` (its override, when any) under `data`. + +Sequence action steps `add_to_segment` and `remove_from_segment` take a `segment_id` and apply the include or exclude override to the contact when the step runs; see [campaigns](/api/reference/campaigns/). + +### Add to a campaign + +`POST /segments/:id/add-to-campaign` + +```json +{ "campaign_id": "…" } +``` + +Enrols every current member as a lead. Contacts already in the campaign are skipped, each new lead gets a `campaign_added` activity, and a running campaign is woken so the leads are scheduled. Returns `{ "campaign_id", "added", "members" }`. This is a snapshot: later members are not added until the call is repeated. Safe to retry. + +Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns` diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index 5e454b86..90e97790 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -14,7 +14,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are | Group | Contents | |-------|----------| | Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included | -| Contacts | Contacts, categories, notes, activities, and the suppression list | +| Contacts | Contacts, categories, segments with their manual overrides, notes, activities, and the suppression list | | Campaigns | Campaigns, sequences, senders, attachments, and per-campaign settings | | CRM | Pipelines, deals, tasks, and meeting bookings | | Automations | Automations, connected integrations, and lead sync sources | From 3bd9325bee973dd7c890ea475134df814bcf320b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 30 Aug 2026 00:11:19 -0700 Subject: [PATCH 20/22] feat: address review on segments: gate the add-to-campaign and from-segment actions behind the manage-campaigns permission on the segment page, the segments list and the campaign Leads tab so the dashboard never offers an enrolment the API would refuse, trim the segment package and migration comments to the one invariant they carry, and drop the em dash from the editor comment --- internal/app/segment/service.go | 5 ++--- .../infrastructure/db/migrations/000110_segments.up.sql | 7 ++----- web/src/app/app/segments/[id]/page.tsx | 4 +++- web/src/app/app/segments/page.tsx | 4 +++- web/src/components/app/contacts/ContactsTable.tsx | 5 ++++- web/src/components/app/segments/SegmentEditor.tsx | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/app/segment/service.go b/internal/app/segment/service.go index 8c6a5537..17275c72 100644 --- a/internal/app/segment/service.go +++ b/internal/app/segment/service.go @@ -1,6 +1,5 @@ -// Package segment manages saved contact audiences (issue #266). A segment is -// a filter definition plus manual overrides; membership is computed at read -// time by the repository's SQL compiler, so nothing here schedules work. +// Package segment manages saved contact audiences; membership is computed at +// read time, so nothing here schedules work. package segment import ( diff --git a/internal/infrastructure/db/migrations/000110_segments.up.sql b/internal/infrastructure/db/migrations/000110_segments.up.sql index 839751b6..dc8ba5c3 100644 --- a/internal/infrastructure/db/migrations/000110_segments.up.sql +++ b/internal/infrastructure/db/migrations/000110_segments.up.sql @@ -1,8 +1,5 @@ --- Contact segments (issue #266): saved, reusable audiences. A segment is a --- filter tree over contacts (properties, categories, campaign activity, --- engagement, other segments) plus per-contact manual overrides. Membership --- is evaluated at read time, so it is always current and never needs a --- recompute job; only the definition and the overrides are stored. +-- Contact segments (issue #266). Membership is evaluated at read time; only +-- the definition and the manual overrides are stored. CREATE TABLE public.segments ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, diff --git a/web/src/app/app/segments/[id]/page.tsx b/web/src/app/app/segments/[id]/page.tsx index 1bf071e8..6131f752 100644 --- a/web/src/app/app/segments/[id]/page.tsx +++ b/web/src/app/app/segments/[id]/page.tsx @@ -37,6 +37,8 @@ function SegmentDetail() { const navigate = useNavigate(); const confirm = useConfirm(); const write = useWriteGuard("MANAGE_CONTACTS"); + // Enrolment writes campaign leads, so it takes the campaign permission. + const campaigns = useWriteGuard("MANAGE_CAMPAIGNS"); const segment = useSegment(id); const fields = useSegmentFields(); const remove = useDeleteSegment(); @@ -104,7 +106,7 @@ function SegmentDetail() { + + + )} + {list.length === 0 ? ( + } onClick={guarded(() => setCreating(true))}> + New category + + ) + } + /> + ) : ( +
+ {list.map((c) => ( + + ))} +
+ )} + + + ); +} + +function CategoryRow({ category, count }: { category: Category; count?: number }) { + const navigate = useNavigate(); + const confirm = useConfirm(); + const write = useWriteGuard("MANAGE_CONTACTS"); + const guarded = (fn: () => void) => () => write.guard(fn)({}); + const update = useUpdateCategory(category.id); + const remove = useDeleteCategory(category.id); + const [renaming, setRenaming] = React.useState(false); + const [title, setTitle] = React.useState(category.title); + + const open = () => navigate(`/app/contacts?category=${category.id}`); + + async function submitRename() { + const next = title.trim(); + if (!next || next === category.title) { + setRenaming(false); + setTitle(category.title); + return; + } + try { + await update.mutateAsync({ title: next }); + toast.success("Category renamed"); + setRenaming(false); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + async function setColor(color: string) { + if (color === category.color) return; + try { + await update.mutateAsync({ color }); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + function askDelete() { + confirm.show(`Delete the category "${category.title}"? It is removed from every contact and inbox thread; the contacts themselves are kept.`, async () => { + try { + await remove.mutateAsync(); + toast.success("Category deleted"); + } catch (err) { + toast.error(buildError(err as AppError)); + } + }); + } + + return ( +
{ + if (!renaming) open(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !renaming) open(); + }} + className="group h-11 px-5 flex items-center gap-3 border-b border-slate-200/60 transition-colors hover:bg-slate-50/80 cursor-pointer" + > + +
renaming && e.stopPropagation()}> + {renaming ? ( +
{ + e.preventDefault(); + void submitRename(); + }} + className="flex items-center gap-1.5" + > + + + + + ) : ( + {category.title} + )} +
+ + {count === undefined ? : count.toLocaleString()} + + contacts + + + + + + View contacts + setRenaming(true))}>Rename +
e.stopPropagation()}> + {COLORS.map((color) => ( +
+ + Delete +
+
+
+ ); +} diff --git a/web/src/app/app/contacts/layout.tsx b/web/src/app/app/contacts/layout.tsx new file mode 100644 index 00000000..aa7b0ac2 --- /dev/null +++ b/web/src/app/app/contacts/layout.tsx @@ -0,0 +1,55 @@ +// Contacts area: one tab strip over the contact list, saved segments and +// categories, since all three are views of the same contact database. + +import { Link, Outlet, useLocation } from "react-router-dom"; +import { motion } from "framer-motion"; +import { LayersIcon, TagIcon, UsersIcon } from "lucide-react"; + +import { NoAccess } from "@/components/layout/NoAccess"; +import { usePermission } from "@/hooks/usePermission"; + +const TABS = [ + { label: "All contacts", path: "", Icon: UsersIcon }, + { label: "Segments", path: "/segments", Icon: LayersIcon }, + { label: "Categories", path: "/categories", Icon: TagIcon }, +] as const; + +export default function ContactsLayout() { + const canView = usePermission("VIEW_CONTACTS"); + const { pathname } = useLocation(); + if (!canView) return ; + + const current = pathname.replace(/\/$/, ""); + return ( +
+
+ {TABS.map(({ label, path, Icon }) => { + const to = `/app/contacts${path}`; + const active = path === "" ? current === to : current.startsWith(to); + return ( + + + {label} + {active && ( + + )} + + ); + })} +
+
+ +
+
+ ); +} diff --git a/web/src/app/app/segments/[id]/page.tsx b/web/src/app/app/contacts/segments/[id]/page.tsx similarity index 97% rename from web/src/app/app/segments/[id]/page.tsx rename to web/src/app/app/contacts/segments/[id]/page.tsx index 6131f752..827e829b 100644 --- a/web/src/app/app/segments/[id]/page.tsx +++ b/web/src/app/app/contacts/segments/[id]/page.tsx @@ -56,7 +56,7 @@ function SegmentDetail() { if (segment.isError || !segment.data) { return (
- + Segments @@ -70,7 +70,7 @@ function SegmentDetail() { try { await remove.mutateAsync(s.id); toast.success("Segment deleted"); - navigate("/app/segments"); + navigate("/app/contacts/segments"); } catch (err) { toast.error(buildError(err as AppError)); } @@ -80,7 +80,7 @@ function SegmentDetail() { return (
- + Segments
diff --git a/web/src/app/app/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx similarity index 96% rename from web/src/app/app/segments/page.tsx rename to web/src/app/app/contacts/segments/page.tsx index 79110828..d16cd008 100644 --- a/web/src/app/app/segments/page.tsx +++ b/web/src/app/app/contacts/segments/page.tsx @@ -54,7 +54,7 @@ function SegmentsList() { conditions: s.conditions, }); toast.success(`Duplicated as ${copy.name}`); - navigate(`/app/segments/${copy.id}`); + navigate(`/app/contacts/segments/${copy.id}`); } catch (err) { toast.error(buildError(err as AppError)); } @@ -158,9 +158,9 @@ function SegmentsList() { key={s.id} role="link" tabIndex={0} - onClick={() => navigate(`/app/segments/${s.id}`)} + onClick={() => navigate(`/app/contacts/segments/${s.id}`)} onKeyDown={(e) => { - if (e.key === "Enter") navigate(`/app/segments/${s.id}`); + if (e.key === "Enter") navigate(`/app/contacts/segments/${s.id}`); }} className="group h-11 px-5 flex items-center gap-3 border-b border-slate-200/60 transition-colors hover:bg-slate-50/80 cursor-pointer" > @@ -198,7 +198,7 @@ function SegmentsList() { - navigate(`/app/segments/${s.id}`)}>View contacts + navigate(`/app/contacts/segments/${s.id}`)}>View contacts openEdit(s))}>Edit conditions setCampaignFor(s))}>Add to campaign duplicate(s))}>Duplicate @@ -217,7 +217,7 @@ function SegmentsList() { onClose={() => setEditorOpen(false)} segment={editing} onSaved={(saved) => { - if (!editing) navigate(`/app/segments/${saved.id}`); + if (!editing) navigate(`/app/contacts/segments/${saved.id}`); }} /> {campaignFor && ( diff --git a/web/src/components/app/contacts/ContactFilters.tsx b/web/src/components/app/contacts/ContactFilters.tsx index a61c574b..d00e8a55 100644 --- a/web/src/components/app/contacts/ContactFilters.tsx +++ b/web/src/components/app/contacts/ContactFilters.tsx @@ -23,6 +23,7 @@ import { CheckIcon, Loader2Icon, PlusIcon, + LayersIcon, RotateCcwIcon, SearchIcon, Trash2Icon, @@ -50,6 +51,9 @@ interface Props { setFilters: React.Dispatch>; activeCampaign?: MiniCampaign; loading?: boolean; + // Offered when at least one filter is set: hands the draft over to be + // saved as a segment. + onSaveAsSegment?: (draft: SearchContacts) => void; } const SORT_OPTIONS: { id: SearchContactsSortBy; label: string }[] = [ @@ -75,6 +79,7 @@ export default function ContactFilters({ setFilters, activeCampaign, loading, + onSaveAsSegment, }: Props) { const [draft, setDraft] = React.useState(filters); @@ -373,6 +378,16 @@ export default function ContactFilters({ Reset + {onSaveAsSegment && activeCount > 0 && ( + + )} -
- - {/* Scrollable body */} -
-
-
- setDraft((s) => ({ ...s, query: v }))} - placeholder="Name, email, company…" - /> -
- -
- setDraft((s) => ({ - ...s, - filters: [ - ...s.filters, - { name: "", value: "", type: "contains" }, - ], - })) - } - className="inline-flex items-center gap-1 text-[11px] text-slate-500 hover:text-slate-900 transition-colors" - > - - Add - - ) : null - } - /> -
- {draft.filters.length === 0 ? ( -

- Add a filter to query custom contact properties. -

- ) : ( - draft.filters.map((f, i) => ( - - setDraft((s) => ({ - ...s, - filters: s.filters.map((it, idx) => - idx === i ? updated : it, - ), - })) - } - onRemove={() => - setDraft((s) => ({ - ...s, - filters: s.filters.filter((_, idx) => idx !== i), - })) - } - /> - )) - )} -
- -
-
- - - o.id === draft.sort_by)?.label ?? "Date added" - } - className="flex-1" - /> - - - Sort by - {SORT_OPTIONS.map((o) => ( - - setDraft((s) => ({ ...s, sort_by: o.id })) - } - > - {o.label} - - ))} - - - -
- -
-
- - setDraft((s) => ({ - ...s, - category_ids: next.length > 0 ? next : undefined, - })) - } - placeholder="Filter by categories…" - /> -

- Contacts must have every selected category. -

-
- -
-
- - setDraft((s) => ({ - ...s, - segment_ids: next.length > 0 ? next : undefined, - })) - } - /> -

- Contacts must be members of every selected segment. -

-
- - {activeCampaign && ( - <> -
-
- setDraft((s) => ({ ...s, lead_status: v }))} - options={LEAD_STATUS_OPTIONS} - /> -
- -
-
- setDraft((s) => ({ ...s, engagement: v }))} - options={ENGAGEMENT_OPTIONS} - /> -

- Opens count people, not mail clients: automatic prefetches are ignored. Not opened, not clicked and not replied only cover leads that have been sent at least one email. -

-
- - )} - -
-
- setDraft((s) => ({ ...s, subscribed: v }))} - options={[ - { id: undefined, label: "Any" }, - { id: true, label: "Subscribed" }, - { id: false, label: "Unsubscribed" }, - ]} - /> -
- -
-
- setDraft((s) => ({ ...s, min_campaigns: v }))} - suffix="campaigns" - /> - setDraft((s) => ({ ...s, max_campaigns: v }))} - suffix="campaigns" - /> -
- -
-
- setDraft((s) => ({ ...s, created_after: v }))} - /> - setDraft((s) => ({ ...s, created_before: v }))} - /> - setDraft((s) => ({ ...s, updated_after: v }))} - /> - setDraft((s) => ({ ...s, updated_before: v }))} - /> -
-
- - {/* Sticky footer */} -
- - {onSaveAsSegment && activeCount > 0 && ( - - )} - - -
- - - )} - - ); -} - -function Section({ label, count, actions }: { label: string; count?: number; actions?: React.ReactNode }) { - return ( - - {actions} - - ); -} - -function FilterRow({ - value, - onChange, - onRemove, -}: { - value: SearchContactsFilter; - onChange: (v: SearchContactsFilter) => void; - onRemove: () => void; -}) { - return ( -
- onChange({ ...value, name: v })} - placeholder="field" - className="min-w-[140px] flex-1 sm:min-w-0" - /> - - - t.id === value.type)?.label ?? "Contains"} - /> - - - {FILTER_TYPES.map((t) => ( - onChange({ ...value, type: t.id })} - > - {t.label} - - ))} - - - onChange({ ...value, value: v })} - placeholder="value" - className="min-w-[140px] flex-1 sm:min-w-0" - /> - -
- ); -} - -// Lead status and engagement choices for the campaign Leads view. "Any" is -// `undefined` so the field is dropped from the request. -const LEAD_STATUS_OPTIONS: { id: LeadStatus | undefined; label: string }[] = [ - { id: undefined, label: "Any" }, - { id: "pending", label: "Queued" }, - { id: "active", label: "Processing" }, - { id: "completed", label: "Done" }, - { id: "replied", label: "Replied" }, - { id: "bounced", label: "Bounced" }, - { id: "failed", label: "Failed" }, - { id: "undeliverable", label: "Undeliverable" }, - { id: "unsubscribed", label: "Unsubscribed" }, -]; - -const ENGAGEMENT_OPTIONS: { id: LeadEngagement | undefined; label: string }[] = [ - { id: undefined, label: "Any" }, - { id: "opened", label: "Opened" }, - { id: "not_opened", label: "Not opened" }, - { id: "clicked", label: "Clicked" }, - { id: "not_clicked", label: "Not clicked" }, - { id: "replied", label: "Replied" }, - { id: "not_replied", label: "Not replied" }, - { id: "bounced", label: "Bounced" }, -]; - -// Wrapping single-choice chips for option sets too long for Toggle3's pill. -function ChoiceRow({ - value, - onChange, - options, -}: { - value: T; - onChange: (v: T) => void; - options: { id: T; label: string }[]; -}) { - return ( -
- {options.map((o) => { - const on = value === o.id; - return ( - - ); - })} -
- ); -} - -function Toggle3({ - value, - onChange, - options, -}: { - value: T; - onChange: (v: T) => void; - options: { id: T; label: string }[]; -}) { - return ( -
- {options.map((o) => ( - - ))} -
- ); -} - -function RangeRow({ - label, - value, - onChange, - suffix, -}: { - label: string; - value: number | undefined; - onChange: (v: number | undefined) => void; - suffix: string; -}) { - const enabled = value !== undefined; - return ( -
- - {label} - onChange(n)} - min={0} - align="right" - placeholder="0" - disabled={!enabled} - className="w-20" - /> - {suffix} -
- ); -} - -function DateRow({ - label, - value, - onChange, -}: { - label: string; - value?: Date; - onChange: (v: Date | undefined) => void; -}) { - const enabled = value !== undefined; - const dateStr = value ? toIsoDate(value) : ""; - return ( -
- - {label} - { - if (!v) onChange(undefined); - else onChange(new Date(v)); - }} - disabled={!enabled} - clearable={false} - placeholder="Pick a date" - className="flex-1" - /> -
- ); -} - -function toIsoDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -function countActiveFilters(f: SearchContacts, hasCampaignContext: boolean): number { - let n = 0; - if (f.query) n++; - n += f.filters.length; - if (f.subscribed !== undefined) n++; - if (f.lead_status) n++; - if (f.engagement) n++; - if (f.min_campaigns !== undefined) n++; - if (f.max_campaigns !== undefined) n++; - if (f.created_after) n++; - if (f.created_before) n++; - if (f.updated_after) n++; - if (f.updated_before) n++; - // Don't count campaign scoping if it's coming from an outer page context. - if (!hasCampaignContext && f.campaign_ids.length > 0) n++; - if (f.category_ids && f.category_ids.length > 0) n++; - if (f.segment_ids && f.segment_ids.length > 0) n++; - return n; -} diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 00a9c1ec..a8710269 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -56,7 +56,8 @@ import { import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import ContactFilters from "./ContactFilters"; +import FilterBar from "./filters/FilterBar"; +import { isCompleteCustomFilter } from "./filters/helpers"; import ContactEdit from "./ContactEdit"; import type { ContactSlideTab } from "./contact-edit/tabs"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; @@ -114,7 +115,6 @@ export default function ContactsTable({ const campaignWrite = useWriteGuard("MANAGE_CAMPAIGNS"); const [selected, setSelected] = React.useState([]); const [del, setDelete] = React.useState(false); - const [filtersOpen, setFiltersOpen] = React.useState(false); const [edit, setEdit] = React.useState(""); // Which tab the drawer opens on. Row click → default (overview); the // right-side 3-dots → "details", mirroring the mailbox 3-dots → settings. @@ -152,11 +152,15 @@ export default function ContactsTable({ function saveAsSegment(draft: SearchContacts) { const { conditions, dropped } = filtersToSegment(draft, current_campaign?.id); - setFiltersOpen(false); if (dropped.length > 0) toast(`Not carried over: ${dropped.join(", ")}. Add a condition for it in the editor.`); setSegmentPreset({ conditions }); } - const contactsData = useSearchContacts({ options: searchProps }); + // Half-filled custom-field pills stay in the bar but never reach the server. + const searchOptions = React.useMemo( + () => ({ ...searchProps, filters: searchProps.filters.filter(isCompleteCustomFilter) }), + [searchProps], + ); + const contactsData = useSearchContacts({ options: searchOptions }); // Inside a segment, "remove" pins the contact out as a manual exclude so // it stays out even while the conditions still match it. @@ -401,13 +405,6 @@ export default function ContactsTable({ placeholder="Search leads…" className="w-full sm:w-56" /> - } - onClick={() => setFiltersOpen(true)} - > - Filters - } @@ -436,6 +433,14 @@ export default function ContactsTable({ Add lead + - - - } - onClick={() => setFiltersOpen(true)} - > - Filters - {searchProps.filters.length > 0 && ( - - {searchProps.filters.length} - - )} - + + {tableNode} @@ -703,15 +695,6 @@ export default function ContactsTable({ {filtered.length === 0 && !contactsData.isPending ? null : null} - setSegmentPreset(null)} diff --git a/web/src/components/app/contacts/filters/FilterBar.tsx b/web/src/components/app/contacts/filters/FilterBar.tsx new file mode 100644 index 00000000..54d616fc --- /dev/null +++ b/web/src/components/app/contacts/filters/FilterBar.tsx @@ -0,0 +1,803 @@ +// Interactive filter bar for the contact list: quick pills for categories, +// segments, status and campaigns, an "Add filter" menu for the rest, and every +// change applied on the spot. Pills open a popover under themselves. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, ChevronDownIcon, LayersIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react"; + +import { DatePicker } from "@/components/ui/DatePicker"; +import { NumberInput, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { SelectMenu } from "@/components/ui/select-menu"; +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import { useUserProfile } from "@/hooks/context/user"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys"; +import { useSegments } from "@/lib/api/hooks/app/segments"; +import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; +import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter"; +import type { SearchContactsFilterType } from "@/lib/api/models/app/contacts/search-contacts.types"; +import type { LeadEngagement, LeadStatus } from "@/lib/api/models/app/contacts/Contact"; +import { cn } from "@/lib/utils"; +import { countActiveFilters, isCompleteCustomFilter } from "./helpers"; + +type Setter = React.Dispatch>; + +const FILTER_TYPES: { id: SearchContactsFilterType; label: string }[] = [ + { id: "contains", label: "contains" }, + { id: "equal", label: "is" }, + { id: "starts_with", label: "starts with" }, + { id: "ends_with", label: "ends with" }, +]; + +const LEAD_STATUS: { id: LeadStatus; label: string }[] = [ + { id: "pending", label: "Queued" }, + { id: "active", label: "Processing" }, + { id: "completed", label: "Done" }, + { id: "replied", label: "Replied" }, + { id: "bounced", label: "Bounced" }, + { id: "failed", label: "Failed" }, + { id: "undeliverable", label: "Undeliverable" }, +]; + +const ENGAGEMENT: { id: LeadEngagement; label: string }[] = [ + { id: "opened", label: "Opened" }, + { id: "not_opened", label: "Not opened" }, + { id: "clicked", label: "Clicked" }, + { id: "not_clicked", label: "Not clicked" }, + { id: "replied", label: "Replied" }, + { id: "not_replied", label: "Not replied" }, +]; + +// Optional pills, shown once added from the menu or when their value is set. +type ExtraKey = "created" | "updated" | "campaign_count" | "lead_status" | "engagement"; + +function toIso(d?: Date): string { + return d ? new Date(d).toISOString().slice(0, 10) : ""; +} +function fromIso(s: string): Date | undefined { + return s ? new Date(`${s}T00:00:00`) : undefined; +} + +export default function FilterBar({ + filters, + setFilters, + activeCampaign, + hideSegments, + total, + loading, + onSaveAsSegment, +}: { + filters: SearchContacts; + setFilters: Setter; + activeCampaign?: MiniCampaign; + // On a segment page the segment scope is fixed, so the pill is hidden. + hideSegments?: boolean; + total: number; + loading?: boolean; + onSaveAsSegment?: (draft: SearchContacts) => void; +}) { + const { user } = useUserProfile(); + const segments = useSegments(); + const customKeys = useCustomFieldKeys(); + const campaignCtx = !!activeCampaign; + + // Pills added from the menu stay visible while empty so the user can fill them. + const [extras, setExtras] = React.useState([]); + const [openKey, setOpenKey] = React.useState(null); + + const shown = (k: ExtraKey) => { + if (extras.includes(k)) return true; + switch (k) { + case "created": + return !!(filters.created_after || filters.created_before); + case "updated": + return !!(filters.updated_after || filters.updated_before); + case "campaign_count": + return filters.min_campaigns !== undefined || filters.max_campaigns !== undefined; + case "lead_status": + return !!filters.lead_status; + case "engagement": + return !!filters.engagement; + } + }; + + function addExtra(k: ExtraKey) { + setExtras((e) => (e.includes(k) ? e : [...e, k])); + setOpenKey(k); + } + function removeExtra(k: ExtraKey) { + setExtras((e) => e.filter((x) => x !== k)); + setFilters((s) => { + switch (k) { + case "created": + return { ...s, created_after: undefined, created_before: undefined }; + case "updated": + return { ...s, updated_after: undefined, updated_before: undefined }; + case "campaign_count": + return { ...s, min_campaigns: undefined, max_campaigns: undefined }; + case "lead_status": + return { ...s, lead_status: undefined }; + case "engagement": + return { ...s, engagement: undefined }; + } + }); + } + + function addCustom() { + setFilters((s) => ({ ...s, filters: [...s.filters, { name: "", value: "", type: "contains" }] })); + setOpenKey(`custom:${filters.filters.length}`); + } + function setCustom(i: number, next: SearchContactsFilter) { + setFilters((s) => ({ ...s, filters: s.filters.map((f, j) => (j === i ? next : f)) })); + } + function removeCustom(i: number) { + setFilters((s) => ({ ...s, filters: s.filters.filter((_, j) => j !== i) })); + setOpenKey(null); + } + + const active = countActiveFilters(filters, campaignCtx); + function clearAll() { + setExtras([]); + setOpenKey(null); + setFilters((s) => ({ + query: s.query, + filters: [], + campaign_ids: activeCampaign ? [activeCampaign.id] : [], + segment_ids: hideSegments ? s.segment_ids : undefined, + sort_by: s.sort_by, + reverse: s.reverse, + })); + } + + const categoryOptions = React.useMemo( + () => [...(user.categories ?? [])].sort((a, b) => a.position - b.position).map((c) => ({ id: c.id, label: c.title, color: c.color })), + [user.categories], + ); + const segmentOptions = React.useMemo( + () => (segments.data ?? []).map((s) => ({ id: s.id, label: s.name, color: s.color })), + [segments.data], + ); + + const menuItems: { key: ExtraKey | "custom"; label: string; hidden?: boolean }[] = [ + { key: "custom", label: "Custom field" }, + { key: "created", label: "Date added", hidden: shown("created") }, + { key: "updated", label: "Last updated", hidden: shown("updated") }, + { key: "campaign_count", label: "Number of campaigns", hidden: shown("campaign_count") }, + { key: "lead_status", label: "Lead status", hidden: !campaignCtx || shown("lead_status") }, + { key: "engagement", label: "Engagement", hidden: !campaignCtx || shown("engagement") }, + ]; + + return ( +
+ setFilters((s) => ({ ...s, category_ids: v.length ? v : undefined }))} + options={categoryOptions} + empty="No categories yet." + hint="Contacts must have every selected category." + /> + {!hideSegments && ( + setFilters((s) => ({ ...s, segment_ids: v.length ? v : undefined }))} + options={segmentOptions} + empty="No segments yet." + hint="Contacts must be in every selected segment." + /> + )} + + id="status" + label="Status" + openKey={openKey} + setOpenKey={setOpenKey} + value={filters.subscribed} + onChange={(v) => setFilters((s) => ({ ...s, subscribed: v }))} + options={[ + { id: true, label: "Subscribed" }, + { id: false, label: "Unsubscribed" }, + ]} + /> + {!campaignCtx && ( + setFilters((s) => ({ ...s, campaign_ids: v }))} + /> + )} + + {filters.filters.map((f, i) => ( + setCustom(i, next)} + onRemove={() => removeCustom(i)} + /> + ))} + {shown("created") && ( + setFilters((s) => ({ ...s, created_after: after, created_before: before }))} + onRemove={() => removeExtra("created")} + /> + )} + {shown("updated") && ( + setFilters((s) => ({ ...s, updated_after: after, updated_before: before }))} + onRemove={() => removeExtra("updated")} + /> + )} + {shown("campaign_count") && ( + setFilters((s) => ({ ...s, min_campaigns: min, max_campaigns: max }))} + onRemove={() => removeExtra("campaign_count")} + /> + )} + {shown("lead_status") && ( + + id="lead_status" + label="Lead status" + openKey={openKey} + setOpenKey={setOpenKey} + value={filters.lead_status} + onChange={(v) => setFilters((s) => ({ ...s, lead_status: v }))} + options={LEAD_STATUS} + onRemove={() => removeExtra("lead_status")} + /> + )} + {shown("engagement") && ( + + id="engagement" + label="Engagement" + openKey={openKey} + setOpenKey={setOpenKey} + value={filters.engagement} + onChange={(v) => setFilters((s) => ({ ...s, engagement: v }))} + options={ENGAGEMENT} + onRemove={() => removeExtra("engagement")} + /> + )} + + + + + + + Filter by + {menuItems + .filter((m) => !m.hidden) + .map((m) => ( + (m.key === "custom" ? addCustom() : addExtra(m.key))}> + {m.label} + + ))} + + + +
+ + {loading && } + {total.toLocaleString()} {activeCampaign ? (total === 1 ? "lead" : "leads") : total === 1 ? "contact" : "contacts"} + + {active > 0 && ( + + )} + {onSaveAsSegment && active > 0 && ( + + )} +
+
+ ); +} + +// Shared pill shell: trigger chip plus a floating panel beneath it. Only one +// pill is open at a time (openKey lives in the bar). +function Pill({ + id, + label, + summary, + active, + openKey, + setOpenKey, + onRemove, + width = 260, + children, +}: { + id: string; + label: string; + summary?: string; + active: boolean; + openKey: string | null; + setOpenKey: (k: string | null) => void; + onRemove?: () => void; + width?: number; + children: React.ReactNode; +}) { + const open = openKey === id; + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => { + if (open) setOpenKey(null); + }); + const placement = useFlipPlacement(triggerRef, open, 300); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpenKey(null); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, setOpenKey]); + + return ( +
+
+ + {onRemove && ( + + )} +
+ + {open && ( + + {children} + + )} + +
+ ); +} + +interface Option { + id: string; + label: string; + color?: string; +} + +function summarize(ids: string[], options: Option[]): string { + if (ids.length === 0) return ""; + const first = options.find((o) => o.id === ids[0])?.label ?? "1"; + return ids.length === 1 ? first : `${first} +${ids.length - 1}`; +} + +function CheckList({ + value, + onChange, + options, + empty, + hint, + searchable = true, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: Option[]; + empty: string; + hint?: string; + searchable?: boolean; +}) { + const [query, setQuery] = React.useState(""); + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options; + }, [options, query]); + const toggle = (id: string) => onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]); + return ( + <> + {searchable && options.length > 6 && ( +
+ setQuery(e.target.value)} + placeholder="Search…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ )} +
+ {filtered.length === 0 &&
{options.length === 0 ? empty : "Nothing matches."}
} + {filtered.map((o) => { + const checked = value.includes(o.id); + return ( + + ); + })} +
+ {(hint || value.length > 0) && ( +
+ {hint && {hint}} + {value.length > 0 && ( + + )} +
+ )} + + ); +} + +function MultiPill({ + id, + label, + openKey, + setOpenKey, + value, + onChange, + options, + empty, + hint, +}: { + id: string; + label: string; + openKey: string | null; + setOpenKey: (k: string | null) => void; + value: string[]; + onChange: (next: string[]) => void; + options: Option[]; + empty: string; + hint?: string; +}) { + return ( + 0} openKey={openKey} setOpenKey={setOpenKey}> + + + ); +} + +function CampaignPill({ + openKey, + setOpenKey, + value, + onChange, +}: { + openKey: string | null; + setOpenKey: (k: string | null) => void; + value: string[]; + onChange: (next: string[]) => void; +}) { + const open = openKey === "campaigns"; + // Only fetch once the pill is opened or already has a value. + const campaigns = useCampaigns({ query: "", folder: "", limit: 100, enabled: open || value.length > 0 }); + const options = React.useMemo(() => campaigns.campaigns.map((c) => ({ id: c.id, label: c.name })), [campaigns.campaigns]); + const { hasNextPage, isFetchingNextPage, fetchNextPage } = campaigns; + React.useEffect(() => { + if (open && hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, [open, hasNextPage, isFetchingNextPage, fetchNextPage]); + return ( + 0} openKey={openKey} setOpenKey={setOpenKey}> + + + ); +} + +function ChoicePill({ + id, + label, + openKey, + setOpenKey, + value, + onChange, + options, + onRemove, +}: { + id: string; + label: string; + openKey: string | null; + setOpenKey: (k: string | null) => void; + value: T; + onChange: (v: T) => void; + options: { id: T; label: string }[]; + onRemove?: () => void; +}) { + const current = options.find((o) => o.id === value); + return ( + +
+ {options.map((o) => { + const on = o.id === value; + return ( + + ); + })} + {value !== undefined && ( + + )} +
+
+ ); +} + +function CustomPill({ + id, + openKey, + setOpenKey, + value, + keys, + onChange, + onRemove, +}: { + id: string; + openKey: string | null; + setOpenKey: (k: string | null) => void; + value: SearchContactsFilter; + keys: string[]; + onChange: (next: SearchContactsFilter) => void; + onRemove: () => void; +}) { + // Free text goes through a short debounce so the list does not refetch per key. + const [text, setText] = React.useState(value.value); + React.useEffect(() => setText(value.value), [value.value]); + React.useEffect(() => { + if (text === value.value) return; + const t = setTimeout(() => onChange({ ...value, value: text }), 300); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text]); + + const complete = isCompleteCustomFilter(value); + const op = FILTER_TYPES.find((t) => t.id === value.type)?.label ?? value.type; + const keyOptions = React.useMemo(() => { + const set = new Set(keys); + if (value.name && !set.has(value.name)) set.add(value.name); + return [...set].map((k) => ({ value: k, label: k })); + }, [keys, value.name]); + + return ( + +
+
+ Field + {keyOptions.length > 0 ? ( + onChange({ ...value, name: v })} options={keyOptions} placeholder="Pick a field" fullWidth /> + ) : ( + onChange({ ...value, name: v })} placeholder="Field name" autoFocus /> + )} +
+
+ Condition + onChange({ ...value, type: v as SearchContactsFilterType })} + options={FILTER_TYPES.map((t) => ({ value: t.id, label: t.label }))} + fullWidth + /> +
+
+ Value + 0} /> +
+
+
+ ); +} + +function DateRangePill({ + id, + label, + openKey, + setOpenKey, + after, + before, + onChange, + onRemove, +}: { + id: string; + label: string; + openKey: string | null; + setOpenKey: (k: string | null) => void; + after?: Date; + before?: Date; + onChange: (after?: Date, before?: Date) => void; + onRemove: () => void; +}) { + const summary = after && before ? `${toIso(after)} to ${toIso(before)}` : after ? `after ${toIso(after)}` : before ? `before ${toIso(before)}` : undefined; + return ( + +
+
+ After + onChange(fromIso(v), before)} className="w-full" /> +
+
+ Before + onChange(after, fromIso(v))} className="w-full" /> +
+
+
+ ); +} + +function RangePill({ + openKey, + setOpenKey, + min, + max, + onChange, + onRemove, +}: { + openKey: string | null; + setOpenKey: (k: string | null) => void; + min?: number; + max?: number; + onChange: (min?: number, max?: number) => void; + onRemove: () => void; +}) { + const summary = min !== undefined && max !== undefined ? `${min} to ${max}` : min !== undefined ? `at least ${min}` : max !== undefined ? `at most ${max}` : undefined; + return ( + +
+ onChange(v, max)} /> + onChange(min, v)} /> +
+
+ ); +} + +function Bound({ label, value, onChange }: { label: string; value?: number; onChange: (v?: number) => void }) { + const set = value !== undefined; + return ( +
+ + {label} + onChange(Math.max(0, v))} min={0} disabled={!set} suffix="campaigns" className="flex-1" /> +
+ ); +} diff --git a/web/src/components/app/contacts/filters/helpers.ts b/web/src/components/app/contacts/filters/helpers.ts new file mode 100644 index 00000000..c60c7740 --- /dev/null +++ b/web/src/components/app/contacts/filters/helpers.ts @@ -0,0 +1,25 @@ +// Pure helpers for the contact filter bar, kept out of the component file so +// fast refresh keeps working. + +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; +import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter"; + +export function isCompleteCustomFilter(f: SearchContactsFilter): boolean { + return f.name.trim() !== "" && f.value.trim() !== ""; +} + +export function countActiveFilters(f: SearchContacts, campaignContext: boolean): number { + let n = 0; + n += f.filters.filter(isCompleteCustomFilter).length; + if (f.category_ids?.length) n++; + if (f.segment_ids?.length) n++; + if (!campaignContext && f.campaign_ids.length > 0) n++; + if (f.subscribed !== undefined) n++; + if (f.min_campaigns !== undefined || f.max_campaigns !== undefined) n++; + if (f.created_after || f.created_before) n++; + if (f.updated_after || f.updated_before) n++; + if (f.lead_status) n++; + if (f.engagement) n++; + return n; +} + diff --git a/web/src/components/app/unibox/UniboxFilterSheet.tsx b/web/src/components/app/unibox/UniboxFilterSheet.tsx index 1c3b82fc..8ed5899d 100644 --- a/web/src/components/app/unibox/UniboxFilterSheet.tsx +++ b/web/src/components/app/unibox/UniboxFilterSheet.tsx @@ -8,9 +8,8 @@ // - since / until date range // - sort: newest / oldest // -// Sheet pattern matches ContactFilters (slim right-side panel, -// sticky header + footer, draft state mirrors parent until Apply -// so we don't refetch while the user is mid-build). +// Slim right-side sheet pattern: sticky header + footer, draft state mirrors parent until Apply +// so we do not refetch while the user is mid-build. import React from "react"; import { AnimatePresence, motion } from "framer-motion";