feat: fix the segment dialog UI and make a contact created inside a segment join it (issue #285): the add-to-campaign picker now shows a humanised, colour-toned campaign status instead of the raw PAUSED_NO_ACCOUNTS enum, its footer wraps instead of clipping the hint mid-sentence and the Add leads button no longer breaks across two lines, campaign status labels move to a shared components/app/campaigns/status module, the Segments, Categories and Segment pages plus every unmapped settings and onboarding route get a document title so they stop reading Page not found, and POST /contacts takes a segments array that pins the new contacts in as include overrides (validated before the write, best-effort after it) which the New contact dialog sends when opened from a segment page

This commit is contained in:
Matthew Meszaros
2026-09-01 02:57:30 -07:00
parent 7b6830daa5
commit 55307b0ad9
13 changed files with 195 additions and 53 deletions
+75
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
@@ -56,10 +57,18 @@ func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID
return nil, xerr
}
// Resolved before the write so an unknown segment is a 400 rather than a
// contact that exists but never reached the segment it was created in.
pins, xerr := s.segmentPins(ctx, orgID, contacts)
if xerr != nil {
return nil, xerr
}
created, xerr := s.contactRepository.Add(ctx, userID, orgID, contacts)
if xerr != nil {
return nil, xerr
}
s.applySegmentPins(ctx, orgID, pins, created)
s.publishContactsReload(ctx, userID, "contacts:add")
var attached []string
@@ -71,6 +80,72 @@ func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID
return created, nil
}
// segmentPins maps each target segment to the positions in `contacts` that
// asked for it. Existence is checked once for the whole batch, so N contacts
// naming the same segment cost one lookup.
func (s *contactService) segmentPins(ctx context.Context, orgID uuid.UUID, contacts []models.AddContact) (map[uuid.UUID][]int, *errx.Error) {
var raw []string
for i := range contacts {
raw = append(raw, contacts[i].Segments...)
}
if len(raw) == 0 {
return nil, nil
}
valid, xerr := s.parseSegmentIDs(ctx, orgID, raw)
if xerr != nil {
return nil, xerr
}
known := make(map[uuid.UUID]bool, len(valid))
for _, id := range valid {
known[id] = true
}
pins := make(map[uuid.UUID][]int, len(valid))
for i := range contacts {
seen := make(map[uuid.UUID]bool, len(contacts[i].Segments))
for _, r := range contacts[i].Segments {
id, err := uuid.Parse(strings.TrimSpace(r))
if err != nil || !known[id] {
return nil, errx.New(errx.BadRequest, "invalid segment id")
}
if seen[id] {
continue
}
seen[id] = true
pins[id] = append(pins[id], i)
}
}
return pins, nil
}
// applySegmentPins writes the include overrides for a batch that has just been
// created. The repository answers one row per input contact, in order, so
// position i of `created` is the contact that position i of the request asked
// to pin. Best effort like wakeCampaigns: the contacts are already stored.
func (s *contactService) applySegmentPins(ctx context.Context, orgID uuid.UUID, pins map[uuid.UUID][]int, created []models.Contact) {
if len(pins) == 0 || s.segmentLinker == nil {
return
}
for segmentID, positions := range pins {
ids := make([]uuid.UUID, 0, len(positions))
for _, i := range positions {
if i < len(created) {
ids = append(ids, created[i].ID)
}
}
if len(ids) == 0 {
continue
}
if _, xerr := s.segmentLinker.SetMembers(ctx, orgID, segmentID, ids, models.SegmentMemberInclude); xerr != nil {
log.Warn().
Str("organization_id", orgID.String()).
Str("segment_id", segmentID.String()).
Int("contacts", len(ids)).
Msg("could not pin the new contacts into their segment")
}
}
}
func (s *contactService) Search(ctx context.Context, orgID, cursor, category, limit string, filters models.SearchContacts) (*models.ContactsResult, *errx.Error) {
cursorId, err := paging.DecodeCursor(cursor)
if err != nil {
+5
View File
@@ -486,6 +486,11 @@ type AddContact struct {
Campaigns []string `json:"campaigns"`
Categories []string `json:"categories"`
// Segments the new contact joins as an include override, so a contact
// created from inside a segment belongs to it whatever the conditions
// say. Unknown ids are a 400 before anything is written.
Segments []string `json:"segments,omitempty"`
CustomFields map[string]string `json:"custom_fields"`
// VerificationStatus is a verdict the caller already holds for this