diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index 1053bdba..1bdca287 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -151,6 +151,7 @@ A JSON array of contact objects (at least one, up to the per-request maximum; an | `phone` | string | No | Phone number. | | `campaigns` | string[] | No | Campaign IDs to add the contact to. | | `categories` | string[] | No | Category IDs to assign. | +| `segments` | string[] | No | Segment IDs to pin the contact into, as a manual include override, so it belongs whether or not the conditions match it. An unknown id is rejected with `400` before any contact is written. | | `custom_fields` | object | No | String key/value custom fields. Keys may use letters, numbers, underscores, spaces, and dashes. | | `subscribed` | boolean | No | Marketing-consent flag. Omit it to let a new contact default to subscribed and an existing one keep whatever it already had. | | `verification_status` | string | No | A verdict you already hold for the address, in Warmbly's vocabulary (`valid`, `risky`, `invalid`, `unknown`) or any known service's (`ok`, `catch-all`, `do_not_mail`, `deliverable`, `ok_for_all`, ...). Stored as an imported verdict that the background check leaves alone. A value no known service writes is rejected with `unknown_verification_status`. | diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx index cd47b724..a79b614f 100644 --- a/docs/content/docs/guides/segments.mdx +++ b/docs/content/docs/guides/segments.mdx @@ -42,7 +42,9 @@ Conditions decide membership, and two overrides sit on top of 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. So can an import: the file import wizard's **Add to segments** picker on its Options step pins every contact in the file into the segments you choose, so a fresh list can land straight in the audience it was collected for. +**New contact** on a segment page pins as well: a contact created from inside a segment joins that segment, so it appears in the list you created it from even when the conditions do not describe it yet. + +Sequences can pin too: 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. So can an import: the file import wizard's **Add to segments** picker on its Options step pins every contact in the file into the segments you choose, so a fresh list can land straight in the audience it was collected for. ## Using a segment diff --git a/internal/app/contact/handler.go b/internal/app/contact/handler.go index 263e6f9c..35af8668 100644 --- a/internal/app/contact/handler.go +++ b/internal/app/contact/handler.go @@ -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 { diff --git a/internal/models/contact.go b/internal/models/contact.go index 1332845a..e1ceb3ea 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -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 diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index d05e16a5..e89ae56c 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -47,6 +47,11 @@ import { TopbarAction, } from "@/components/layout/Page"; import { SearchInput } from "@/components/ui/field"; +import { + CAMPAIGN_STATUS_LABEL, + campaignStatusBucket as statusBucket, + campaignStatusTone as statusTone, +} from "@/components/app/campaigns/status"; import { PopoverMenu, PopoverMenuContent, @@ -60,47 +65,11 @@ import { type StatusFilter = "all" | "active" | "paused" | "draft" | "completed"; type SortMode = "newest" | "oldest" | "name"; -// Collapse the backend's raw campaign statuses into the buckets the list -// filters/counts by. paused_no_accounts / paused_trial_expired are auto-pause -// variants, so they belong with "paused"; anything unknown reads as draft. -function statusBucket(s?: string): "active" | "paused" | "completed" | "draft" { - if (s === "active") return "active"; - if (s === "completed") return "completed"; - if (s && s.startsWith("paused")) return "paused"; - return "draft"; -} - // Per-state label + leading mark for a campaign row. "active" renders the // animated dot-grid loader; every other state is a 14px lucide icon so the -// fixed-width leading slot keeps each row's name aligned. -const STATUS_LABEL: Record = { - active: "running", - paused: "paused", - paused_no_accounts: "no accounts", - paused_trial_expired: "trial expired", - paused_guardrail: "auto-paused", - paused_undeliverable: "needs verification", - completed: "finished", - draft: "draft", -}; - -// Single source of truth for a status's color — drives BOTH the leading mark -// and the right-side text label so they always agree. emerald = live/done, -// amber = paused/needs-attention, slate = not started. -const STATUS_TONE: Record = { - active: "text-emerald-600", - completed: "text-emerald-600", - paused: "text-amber-600", - paused_no_accounts: "text-amber-600", - paused_trial_expired: "text-amber-600", - paused_guardrail: "text-rose-600", - paused_undeliverable: "text-amber-600", - draft: "text-slate-500", -}; - -function statusTone(status: string): string { - return STATUS_TONE[status] ?? STATUS_TONE.draft; -} +// fixed-width leading slot keeps each row's name aligned. The label/tone maps +// live in components/app/campaigns/status so pickers share them. +const STATUS_LABEL = CAMPAIGN_STATUS_LABEL; function CampaignStatusMark({ status }: { status: string }) { const tone = statusTone(status); diff --git a/web/src/components/app/AddContacts.tsx b/web/src/components/app/AddContacts.tsx index e415c540..c81062fd 100644 --- a/web/src/components/app/AddContacts.tsx +++ b/web/src/components/app/AddContacts.tsx @@ -32,6 +32,9 @@ export interface AddContact { phone: string; campaigns: string[]; categories?: string[]; + // Segments the contact is pinned into on creation (an include override), + // so a contact created from inside a segment lands in it. + segments?: string[]; custom_fields: Record; // First-touch source hint: "campaign" when added from a campaign's // Leads tab, else "manual". The server decides every other origin. diff --git a/web/src/components/app/campaigns/status.ts b/web/src/components/app/campaigns/status.ts new file mode 100644 index 00000000..466a8f6c --- /dev/null +++ b/web/src/components/app/campaigns/status.ts @@ -0,0 +1,49 @@ +// One place every surface reads a campaign's status from, so a list row, a +// picker row and a badge never disagree (and none of them leaks the raw +// backend value like "paused_no_accounts" at the user). + +// Collapse the backend's raw campaign statuses into the buckets a list +// filters/counts by. paused_no_accounts / paused_trial_expired are auto-pause +// variants, so they belong with "paused"; anything unknown reads as draft. +export function campaignStatusBucket(s?: string): "active" | "paused" | "completed" | "draft" { + if (s === "active") return "active"; + if (s === "completed") return "completed"; + if (s && s.startsWith("paused")) return "paused"; + return "draft"; +} + +export const CAMPAIGN_STATUS_LABEL: Record = { + active: "running", + paused: "paused", + paused_no_accounts: "no accounts", + paused_trial_expired: "trial expired", + paused_guardrail: "auto-paused", + paused_undeliverable: "needs verification", + completed: "finished", + draft: "draft", +}; + +// Single source of truth for a status's color — drives BOTH the leading mark +// and the right-side text label so they always agree. emerald = live/done, +// amber = paused/needs-attention, slate = not started. +const CAMPAIGN_STATUS_TONE: Record = { + active: "text-emerald-600", + completed: "text-emerald-600", + paused: "text-amber-600", + paused_no_accounts: "text-amber-600", + paused_trial_expired: "text-amber-600", + paused_guardrail: "text-rose-600", + paused_undeliverable: "text-amber-600", + draft: "text-slate-500", +}; + +// An unmapped status still has to read as words, not an enum: underscores +// become spaces rather than surfacing "PAUSED_NO_ACCOUNTS". +export function campaignStatusLabel(status?: string): string { + if (!status) return CAMPAIGN_STATUS_LABEL.draft; + return CAMPAIGN_STATUS_LABEL[status] ?? status.replace(/_/g, " "); +} + +export function campaignStatusTone(status: string): string { + return CAMPAIGN_STATUS_TONE[status] ?? CAMPAIGN_STATUS_TONE.draft; +} diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index a048073f..12fb5683 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -789,7 +789,7 @@ export default function ContactsTable({ /> - setNewOpen(false)} /> + setNewOpen(false)} segment={segment} /> {segment && ( buildError(err), }); onClose(); @@ -108,6 +113,11 @@ export function NewContactDialog({ open, onClose, campaign }: Props) { Contact + {segment && ( + + {segment.name} + + )} ); @@ -170,15 +178,17 @@ export default function AddSegmentToCampaignDialog({ )} -