diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 56c2aaca..e0662378 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 { @@ -1282,6 +1286,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 @@ -1484,6 +1491,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) @@ -1814,6 +1829,7 @@ func main() { AnalyticsService: analyticsService, RateLimitService: rateLimitService, ContactService: contactService, + SegmentService: segmentService, SequenceService: sequenceService, UniboxService: uniboxService, diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index ff0fbc5f..d6b65e74 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -292,6 +292,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) // Replies, bounces, opens and clicks teach verification what real mail // showed about each address. diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index cc8cbfe2..1c36515a 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -84,6 +84,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` | @@ -103,6 +104,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 a40dcdc0..f4a4dbe1 100644 --- a/docs/content/docs/api/reference/campaigns.mdx +++ b/docs/content/docs/api/reference/campaigns.mdx @@ -877,7 +877,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 bc7dabf5..e98f6d84 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. | | `verification_status` | string | No | Filter by verification verdict: `valid`, `risky`, `invalid`, or `unknown`. | | `min_campaigns` | integer | No | Minimum number of associated campaigns. | | `max_campaigns` | integer | No | Maximum number of associated campaigns. | @@ -978,3 +979,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/contacts-crm.mdx b/docs/content/docs/guides/contacts-crm.mdx index bd82a564..a5f702c7 100644 --- a/docs/content/docs/guides/contacts-crm.mdx +++ b/docs/content/docs/guides/contacts-crm.mdx @@ -65,13 +65,21 @@ Both forms work everywhere, including inside `{{if}}` conditions and helper func See [Personalization & expressions](/guides/expressions/) for the full templating language. +## Filtering the list + +A filter bar sits above the contact list. **Category**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search and sort stay in the toolbar. + ## Categories Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`), behaving like tags. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards. 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. +**Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it. + +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 +129,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..c274c237 --- /dev/null +++ b/docs/content/docs/guides/segments.mdx @@ -0,0 +1,71 @@ +--- +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 **Contacts > Segments**, next to **All contacts** and **Categories**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete. + +## Building a segment + +The quickest start is from the contact list: set any filters in the bar above the list, then press **Save as segment** and the editor opens with those filters as conditions (free-text search and a campaign's lead status do not carry over; add a condition for them instead). Or start from scratch with **New segment** on the Segments tab. + +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 contact list's filter bar has a **Segment** pill, 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 + + + + + + diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index be55631b..bbe2a32f 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 | 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 66bba3ca..9684da0a 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -578,6 +578,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 @@ -596,6 +597,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)) 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 52223e71..ca879ddd 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -157,6 +157,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/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index a39cdeae..daad34c7 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -263,6 +263,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/app/segment/service.go b/internal/app/segment/service.go new file mode 100644 index 00000000..17275c72 --- /dev/null +++ b/internal/app/segment/service.go @@ -0,0 +1,292 @@ +// Package segment manages saved contact audiences; membership is computed at +// read time, 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) +} diff --git a/internal/infrastructure/db/migrations/000112_segments.down.sql b/internal/infrastructure/db/migrations/000112_segments.down.sql new file mode 100644 index 00000000..63a2fb08 --- /dev/null +++ b/internal/infrastructure/db/migrations/000112_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/000112_segments.up.sql b/internal/infrastructure/db/migrations/000112_segments.up.sql new file mode 100644 index 00000000..dc8ba5c3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000112_segments.up.sql @@ -0,0 +1,35 @@ +-- 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, + 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); 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/contact.go b/internal/models/contact.go index ccb87855..1332845a 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -569,6 +569,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/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) + } +} 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.go b/internal/repository/pg_contact.go index 03085b48..0505b4d3 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -1026,6 +1026,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 // ----------------------------- @@ -1151,6 +1175,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) 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/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 +} 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 +} 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) + } +} 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 ac1cbdf0..10d908d1 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -965,6 +965,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) +} diff --git a/web/src/app/app/contacts/categories/page.tsx b/web/src/app/app/contacts/categories/page.tsx new file mode 100644 index 00000000..e8605209 --- /dev/null +++ b/web/src/app/app/contacts/categories/page.tsx @@ -0,0 +1,275 @@ +// Categories tab: the workspace's contact labels with a live contact count, +// inline rename, color, create and delete. Clicking a row opens the contact +// list filtered to that category. + +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useQueries } from "@tanstack/react-query"; +import { CheckIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page"; +import { SearchInput, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { useConfirm } from "@/hooks/context/confirm"; +import { useUserProfile } from "@/hooks/context/user"; +import { useWriteGuard } from "@/hooks/usePermission"; +import useCreateCategory from "@/lib/api/hooks/app/categories/useCreateCategory"; +import useDeleteCategory from "@/lib/api/hooks/app/categories/useDeleteCategory"; +import useUpdateCategory from "@/lib/api/hooks/app/categories/useUpdateCategory"; +import { previewSegment } from "@/lib/api/client/app/segments"; +import type Category from "@/lib/api/models/app/Category"; +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"]; + +export default function CategoriesPage() { + const { user } = useUserProfile(); + const write = useWriteGuard("MANAGE_CONTACTS"); + const guarded = (fn: () => void) => () => write.guard(fn)({}); + const create = useCreateCategory(); + const [query, setQuery] = React.useState(""); + const [creating, setCreating] = React.useState(false); + const [newTitle, setNewTitle] = React.useState(""); + + const categories = React.useMemo( + () => [...(user.categories ?? [])].sort((a, b) => a.position - b.position), + [user.categories], + ); + const list = React.useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? categories.filter((c) => c.title.toLowerCase().includes(q)) : categories; + }, [categories, query]); + + // One live count per category through the segment preview, so the number + // agrees with what a "has any of" condition would match. + const counts = useQueries({ + queries: categories.map((c) => ({ + queryKey: ["segments", "preview", "category", c.id], + queryFn: () => previewSegment({ match: "all", conditions: [{ field: "category", operator: "in", values: [c.id] }] }), + staleTime: 30_000, + })), + }); + const countById = new Map(); + categories.forEach((c, i) => countById.set(c.id, counts[i]?.data)); + + async function submitCreate() { + const title = newTitle.trim(); + if (!title || create.isPending) return; + try { + await create.mutateAsync(title); + toast.success(`Created ${title}`); + setNewTitle(""); + setCreating(false); + } catch (err) { + toast.error(buildError(err as AppError)); + } + } + + return ( + + + } onClick={guarded(() => setCreating(true))}> + New category + + + + + + + + + {creating && ( +
{ + e.preventDefault(); + void submitCreate(); + }} + className="h-11 px-5 flex items-center gap-2 border-b border-slate-200/60 bg-sky-50/40" + > + + + + + )} + {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/contacts/segments/[id]/page.tsx b/web/src/app/app/contacts/segments/[id]/page.tsx new file mode 100644 index 00000000..827e829b --- /dev/null +++ b/web/src/app/app/contacts/segments/[id]/page.tsx @@ -0,0 +1,250 @@ +// 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"); + // 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(); + 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/contacts/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/contacts/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx new file mode 100644 index 00000000..d16cd008 --- /dev/null +++ b/web/src/app/app/contacts/segments/page.tsx @@ -0,0 +1,228 @@ +// 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 campaigns = useWriteGuard("MANAGE_CAMPAIGNS"); + const campaignGuarded = (fn: () => void) => () => campaigns.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/contacts/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/contacts/segments/${s.id}`)} + onKeyDown={(e) => { + 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" + > + +
+
+ {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/contacts/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/contacts/segments/${saved.id}`); + }} + /> + {campaignFor && ( + setCampaignFor(null)} segment={campaignFor} /> + )} + + ); +} 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/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/ContactFilters.tsx b/web/src/components/app/contacts/ContactFilters.tsx deleted file mode 100644 index 0a1dfd95..00000000 --- a/web/src/components/app/contacts/ContactFilters.tsx +++ /dev/null @@ -1,665 +0,0 @@ -// Contact filters — brae-density side sheet. -// -// Replaces the legacy 800px poppins-serif drawer. New panel is 400px, -// edge-to-edge with a sticky topbar and sticky footer; each filter -// group sits between hairline SectionBars so they read as a quiet -// outline of the available knobs rather than a heavy form. -// -// Local state mirrors the parent's `filters` until the user clicks -// Apply — that way fiddling with filters doesn't immediately retrigger -// the search while they're still building the query. - -import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; -import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter"; -import type { SearchContactsFilterType, SearchContactsSortBy } from "@/lib/api/models/app/contacts/search-contacts.types"; -import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; -import type { LeadEngagement, LeadStatus, VerificationStatus } from "@/lib/api/models/app/contacts/Contact"; - -import React from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { - ArrowDownIcon, - ArrowUpIcon, - CheckIcon, - Loader2Icon, - PlusIcon, - RotateCcwIcon, - SearchIcon, - Trash2Icon, - XIcon, -} from "lucide-react"; - -import { NumberInput, SearchInput, TextInput } from "@/components/ui/field"; -import { DatePicker } from "@/components/ui/DatePicker"; -import { - PopoverMenu, - PopoverMenuContent, - PopoverMenuItem, - PopoverMenuLabel, - PopoverMenuTrigger, - SelectButton, -} from "@/components/ui/popover-menu"; -import { SectionBar } from "@/components/layout/Page"; -import CategoryPicker from "./CategoryPicker"; - -interface Props { - active: boolean; - setActive: React.Dispatch>; - filters: SearchContacts; - setFilters: React.Dispatch>; - activeCampaign?: MiniCampaign; - loading?: boolean; -} - -const SORT_OPTIONS: { id: SearchContactsSortBy; label: string }[] = [ - { id: "created_at", label: "Date added" }, - { id: "updated_at", label: "Last updated" }, - { id: "first_name", label: "First name" }, - { id: "last_name", label: "Last name" }, - { id: "email", label: "Email" }, - { id: "campaign_count", label: "Campaigns count" }, -]; - -const FILTER_TYPES: { id: SearchContactsFilterType; label: string }[] = [ - { id: "contains", label: "Contains" }, - { id: "equal", label: "Equals" }, - { id: "starts_with", label: "Starts with" }, - { id: "ends_with", label: "Ends with" }, -]; - -export default function ContactFilters({ - active, - setActive, - filters, - setFilters, - activeCampaign, - loading, -}: Props) { - const [draft, setDraft] = React.useState(filters); - - // When the parent passes in new committed filters, mirror them into - // the draft so re-opening the sheet doesn't show a stale state. - React.useEffect(() => { - if (active) setDraft(filters); - }, [active, filters]); - - const activeCount = countActiveFilters(draft, !!activeCampaign); - - const apply = () => { - setFilters(draft); - setActive(false); - }; - const reset = () => { - const empty: SearchContacts = { - query: "", - filters: [], - campaign_ids: activeCampaign ? [activeCampaign.id] : [], - sort_by: "created_at", - reverse: false, - }; - setDraft(empty); - }; - - return ( - - {active && ( - setActive(false)} - className="fixed inset-0 z-[100] flex justify-end bg-slate-900/30 backdrop-blur-[2px]" - > - e.stopPropagation()} - className="flex flex-col bg-white w-[420px] max-w-[95%] h-full border-l border-slate-200 shadow-[-8px_0_24px_-12px_rgba(15,23,42,0.12)]" - > - {/* Sticky header */} -
- - Filters - -
- - {activeCount === 0 - ? "No filters applied" - : `${activeCount} ${activeCount === 1 ? "filter" : "filters"} active`} - - -
- - {/* 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. -

-
- - {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, verification_status: v }))} - options={VERIFICATION_OPTIONS} - /> -

- Undeliverable addresses are never sent to. Risky ones (catch-all domains, shared inboxes) send only when the campaign allows it. -

-
- -
-
- 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 */} -
- - - -
- - - )} - - ); -} - -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 VERIFICATION_OPTIONS: { id: VerificationStatus | undefined; label: string }[] = [ - { id: undefined, label: "Any" }, - { id: "valid", label: "Deliverable" }, - { id: "risky", label: "Risky" }, - { id: "invalid", label: "Undeliverable" }, - { id: "unknown", label: "Unverified" }, -]; - -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.verification_status) 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++; - return n; -} diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index ed08b727..89b284a9 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -11,6 +11,7 @@ // section header so it nests cleanly under the campaign view. import React from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { AlertTriangleIcon, BanIcon, @@ -20,6 +21,7 @@ import { ClockIcon, CornerUpLeftIcon, DownloadIcon, + LayersIcon, Loader2Icon, MailIcon, MailOpenIcon, @@ -36,9 +38,11 @@ import { UploadIcon, UserPlusIcon, UsersIcon, + XIcon, } from "lucide-react"; import { useConfirm } from "@/hooks/context/confirm"; +import { useWriteGuard } from "@/hooks/usePermission"; import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts"; import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts"; @@ -55,7 +59,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"; @@ -66,6 +71,12 @@ import { NewContactDialog } from "./NewContactDialog"; import ExportDialog from "./ExportDialog"; import ImportWizard from "./ImportWizard"; import AddFromContactsDialog from "./AddFromContactsDialog"; +import AddToSegmentMenu from "@/components/app/segments/AddToSegmentMenu"; +import SegmentEditor from "@/components/app/segments/SegmentEditor"; +import { filtersToSegment } from "@/components/app/segments/filtersToSegment"; +import type { SegmentCondition } from "@/lib/api/models/app/segments/Segment"; +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"; @@ -95,13 +106,18 @@ 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(); + // Enrolling a segment writes campaign leads, so it takes the campaign permission. + 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. @@ -117,15 +133,50 @@ 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); + // A filter saved as a segment: the panel's draft becomes the editor's preset. + const [segmentPreset, setSegmentPreset] = React.useState<{ conditions: SegmentCondition[] } | null>(null); + const navigate = useNavigate(); + // ?category= pre-filters the list (the Categories tab links here). + const [params] = useSearchParams(); - const [searchProps, setSearchProps] = React.useState({ - query: "", - filters: [], - campaign_ids: current_campaign ? [current_campaign.id] : [], - sort_by: "created_at", - reverse: false, + const [searchProps, setSearchProps] = React.useState(() => { + const category = params.get("category"); + return { + query: "", + filters: [], + campaign_ids: current_campaign ? [current_campaign.id] : [], + segment_ids: segment ? [segment.id] : undefined, + category_ids: category && !segment && !current_campaign ? [category] : undefined, + sort_by: "created_at", + reverse: false, + }; }); - const contactsData = useSearchContacts({ options: searchProps }); + + function saveAsSegment(draft: SearchContacts) { + const { conditions, dropped } = filtersToSegment(draft, current_campaign?.id); + if (dropped.length > 0) toast(`Not carried over: ${dropped.join(", ")}. Add a condition for it in the editor.`); + setSegmentPreset({ conditions }); + } + // 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. + 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 @@ -347,6 +398,13 @@ export default function ContactsTable({ > From contacts + } + onClick={() => campaignWrite.guard(() => setFromSegmentOpen(true))({})} + > + From segment + } @@ -380,13 +438,6 @@ export default function ContactsTable({ placeholder="Search leads…" className="w-full sm:w-56" /> - } - onClick={() => setFiltersOpen(true)} - > - Filters - } @@ -415,6 +466,14 @@ export default function ContactsTable({ Add lead + setSelected([])} - /> - setFromContactsOpen(false)} campaign={current_campaign} /> + setFromSegmentOpen(false)} + campaign={current_campaign} + /> ); } @@ -483,15 +543,26 @@ export default function ContactsTable({ return ( + {segment && ( + } + onClick={() => setFromContactsOpen(true)} + > + Add contacts + + )}
- + {!segment && - + } - - } - onClick={() => setFiltersOpen(true)} - > - Filters - {searchProps.filters.length > 0 && ( - - {searchProps.filters.length} - - )} - + + {tableNode} @@ -659,21 +726,30 @@ export default function ContactsTable({ ) } onClear={() => setSelected([])} + selected={selected} + segment={segment} + onExclude={excludeFromSegment} + excluding={segmentMembers.isPending} /> {filtered.length === 0 && !contactsData.isPending ? null : null} - setSegmentPreset(null)} + preset={segmentPreset} + onSaved={(saved) => navigate(`/app/contacts/segments/${saved.id}`)} /> setNewOpen(false)} /> + {segment && ( + setFromContactsOpen(false)} + target={{ kind: "segment", segment }} + /> + )} setExportOpen(false)} @@ -1370,6 +1446,10 @@ function SelectionBar({ verifying, onDelete, onClear, + selected, + segment, + onExclude, + excluding, }: { count: number; deleting: boolean; @@ -1384,6 +1464,10 @@ function SelectionBar({ verifying: boolean; onDelete: () => void; onClear: () => void; + selected: string[]; + segment?: { id: string; name: string }; + onExclude: () => void; + excluding: boolean; }) { if (count === 0) return null; return ( @@ -1434,6 +1518,18 @@ function SelectionBar({ > Edit + + {segment && ( + + )} + ); +} diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index 5311e171..ee0782fd 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"; import VerificationCard from "./VerificationCard"; export default function OverviewTab({ @@ -184,6 +185,8 @@ export default function OverviewTab({
+ + {detail && (
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..1619494e --- /dev/null +++ b/web/src/components/app/contacts/filters/FilterBar.tsx @@ -0,0 +1,827 @@ +// 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, VerificationStatus } 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" }, +]; + +const VERIFICATION: { id: VerificationStatus; label: string }[] = [ + { id: "valid", label: "Deliverable" }, + { id: "risky", label: "Risky" }, + { id: "invalid", label: "Undeliverable" }, + { id: "unknown", label: "Unverified" }, +]; + +// Optional pills, shown once added from the menu or when their value is set. +type ExtraKey = "created" | "updated" | "campaign_count" | "lead_status" | "engagement" | "verification"; + +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; + case "verification": + return !!filters.verification_status; + } + }; + + 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 }; + case "verification": + return { ...s, verification_status: 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: "verification", label: "Address verification", hidden: shown("verification") }, + { 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("verification") && ( + + id="verification" + label="Verification" + openKey={openKey} + setOpenKey={setOpenKey} + value={filters.verification_status} + onChange={(v) => setFilters((s) => ({ ...s, verification_status: v }))} + options={VERIFICATION} + onRemove={() => removeExtra("verification")} + /> + )} + {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..c8e5172d --- /dev/null +++ b/web/src/components/app/contacts/filters/helpers.ts @@ -0,0 +1,26 @@ +// 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.verification_status) 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/segments/AddSegmentLeadsDialog.tsx b/web/src/components/app/segments/AddSegmentLeadsDialog.tsx new file mode 100644 index 00000000..04fd6f81 --- /dev/null +++ b/web/src/components/app/segments/AddSegmentLeadsDialog.tsx @@ -0,0 +1,193 @@ +// From a campaign's Leads tab: pick a segment and enrol its current members +// as leads. The mirror image of AddSegmentToCampaignDialog. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { LayersIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { SearchInput } from "@/components/ui/field"; +import { useAddSegmentToCampaign, useSegments } from "@/lib/api/hooks/app/segments"; +import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import { cn } from "@/lib/utils"; + +export default function AddSegmentLeadsDialog({ + open, + onClose, + campaign, +}: { + open: boolean; + onClose: () => void; + campaign: MiniCampaign; +}) { + const add = useAddSegmentToCampaign(); + const segments = useSegments(open); + const [query, setQuery] = React.useState(""); + const [picked, setPicked] = React.useState(null); + + React.useEffect(() => { + if (!open) { + setQuery(""); + setPicked(null); + } + }, [open]); + + const list = React.useMemo(() => { + const all = segments.data ?? []; + const q = query.trim().toLowerCase(); + return q ? all.filter((s) => s.name.toLowerCase().includes(q)) : all; + }, [segments.data, query]); + + 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 seg = list.find((s) => s.id === picked); + try { + const res = await add.mutateAsync({ id: picked, campaignId: campaign.id }); + toast.success( + res.added === 0 + ? `Every member of ${seg?.name ?? "the segment"} is already a lead` + : `Added ${res.added.toLocaleString()} lead${res.added === 1 ? "" : "s"} from ${seg?.name ?? "the segment"}`, + ); + 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 leads +
+ From a segment + + → {campaign.name} + + +
+
+ +
+
+ {segments.isPending ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : list.length === 0 ? ( +
+

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

+

Build one under Contacts > Segments 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..d326a8cc --- /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 under Contacts > Segments.
+ )} + {list.map((s) => ( + add(s.id, s.name)}> + + + {s.name} + + + ))} +
+
+ ); +} diff --git a/web/src/components/app/segments/SegmentEditor.tsx b/web/src/components/app/segments/SegmentEditor.tsx new file mode 100644 index 00000000..ae987c74 --- /dev/null +++ b/web/src/components/app/segments/SegmentEditor.tsx @@ -0,0 +1,501 @@ +// 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[]; +} + +// Starting conditions for a new segment, e.g. saved from the filter panel. +export interface SegmentPreset { + conditions: SegmentCondition[]; +} + +function draftFrom(segment?: Segment | null, preset?: SegmentPreset | null): Draft { + const conditions = segment?.conditions ?? preset?.conditions ?? []; + return { + name: segment?.name ?? "", + description: segment?.description ?? "", + color: segment?.color ?? COLORS[0], + match: segment?.match ?? "all", + conditions: 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, + preset, + onSaved, +}: { + open: boolean; + onClose: () => void; + // Edit this segment; omit to create a new one. + segment?: Segment | null; + // Pre-filled conditions for a new segment; ignored when editing. + preset?: SegmentPreset | 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, preset)); + const [initial, setInitial] = React.useState(() => draftFrom(segment, preset)); + React.useEffect(() => { + if (open) { + const d = draftFrom(segment, preset); + setDraft(d); + setInitial(d); + } + }, [open, segment, preset]); + 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 ; +} diff --git a/web/src/components/app/segments/filtersToSegment.ts b/web/src/components/app/segments/filtersToSegment.ts new file mode 100644 index 00000000..ed6431b5 --- /dev/null +++ b/web/src/components/app/segments/filtersToSegment.ts @@ -0,0 +1,69 @@ +// Turns the contact filter panel's state into segment conditions so a filter +// can be saved as a segment. Returns what could not be carried over. + +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; +import type { SegmentCondition } from "@/lib/api/models/app/segments/Segment"; + +export interface SegmentPreset { + conditions: SegmentCondition[]; + dropped: string[]; +} + +const TEXT_OPS: Record = { + equal: "equals", + contains: "contains", + starts_with: "starts_with", + ends_with: "ends_with", +}; + +function isoDate(d: Date): string { + return new Date(d).toISOString().slice(0, 10); +} + +export function filtersToSegment(f: SearchContacts, campaignID?: string): SegmentPreset { + const conditions: SegmentCondition[] = []; + const dropped: string[] = []; + + for (const cf of f.filters) { + const name = cf.name.trim(); + const op = TEXT_OPS[cf.type]; + if (!name || !op) continue; + conditions.push({ field: `custom.${name}`, operator: op, value: cf.value }); + } + if (f.category_ids && f.category_ids.length > 0) { + // The filter panel requires every category; one condition per id keeps that. + for (const id of f.category_ids) conditions.push({ field: "category", operator: "in", values: [id] }); + } + if (f.segment_ids && f.segment_ids.length > 0) { + for (const id of f.segment_ids) conditions.push({ field: "segment", operator: "in", values: [id] }); + } + if (f.subscribed !== undefined) { + conditions.push({ field: "subscribed", operator: f.subscribed ? "is_true" : "is_false" }); + } + if (f.verification_status) conditions.push({ field: "verification_status", operator: "in", values: [f.verification_status] }); + if (f.min_campaigns !== undefined) conditions.push({ field: "campaign_count", operator: "gte", value: String(f.min_campaigns) }); + if (f.max_campaigns !== undefined) conditions.push({ field: "campaign_count", operator: "lte", value: String(f.max_campaigns) }); + if (f.created_after) conditions.push({ field: "created_at", operator: "after", value: isoDate(f.created_after) }); + if (f.created_before) conditions.push({ field: "created_at", operator: "before", value: isoDate(f.created_before) }); + if (f.updated_after) conditions.push({ field: "updated_at", operator: "after", value: isoDate(f.updated_after) }); + if (f.updated_before) conditions.push({ field: "updated_at", operator: "before", value: isoDate(f.updated_before) }); + if (campaignID) conditions.push({ field: "campaign", operator: "in", values: [campaignID] }); + + // Engagement buckets become lifetime counters; the not_* forms only cover + // contacts that were sent something, like the filter does. + if (f.engagement) { + const positive = f.engagement.startsWith("not_") ? f.engagement.slice(4) : f.engagement; + const field = `emails_${positive}`; + if (f.engagement.startsWith("not_")) { + conditions.push({ field: "emails_sent", operator: "gte", value: "1" }); + conditions.push({ field, operator: "equals", value: "0" }); + } else { + conditions.push({ field, operator: "gte", value: "1" }); + } + } + + if (f.query.trim()) dropped.push("search text"); + if (f.lead_status) dropped.push("lead status"); + + return { conditions, dropped }; +} 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"; diff --git a/web/src/components/layout/AppHeader.tsx b/web/src/components/layout/AppHeader.tsx index 502f5cc2..d3995699 100644 --- a/web/src/components/layout/AppHeader.tsx +++ b/web/src/components/layout/AppHeader.tsx @@ -33,6 +33,8 @@ const labelMap: Record = { emails: "Accounts", unibox: "Inbox", contacts: "Contacts", + segments: "Segments", + categories: "Categories", campaigns: "Campaigns", analytics: "Analytics", crm: "CRM", diff --git a/web/src/components/layout/DynamicBreadcrumb.tsx b/web/src/components/layout/DynamicBreadcrumb.tsx index bdd919d9..ead2f154 100644 --- a/web/src/components/layout/DynamicBreadcrumb.tsx +++ b/web/src/components/layout/DynamicBreadcrumb.tsx @@ -5,6 +5,8 @@ const labelMap: Record = { app: 'Dashboard', emails: 'Accounts', contacts: 'Contacts', + segments: 'Segments', + categories: 'Categories', campaigns: 'Campaigns', unibox: 'Inbox', analytics: 'Analytics', 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/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/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. diff --git a/web/src/lib/api/models/app/contacts/SearchContacts.ts b/web/src/lib/api/models/app/contacts/SearchContacts.ts index 7de11579..7de203f3 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; 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"]); diff --git a/web/src/main.tsx b/web/src/main.tsx index c74ef23b..6c9f808b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -12,6 +12,10 @@ 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 ContactsLayout from './app/app/contacts/layout'; +import SegmentsPage from './app/app/contacts/segments/page'; +import CategoriesPage from './app/app/contacts/categories/page'; +import SegmentPage from './app/app/contacts/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'; @@ -235,7 +239,18 @@ const router = createBrowserRouter([ }, { path: "contacts", - element: , + element: , + children: [ + { index: true, element: }, + { + path: "segments", + children: [ + { index: true, element: }, + { path: ":id", element: }, + ], + }, + { path: "categories", element: }, + ], }, { path: "campaigns",